From 65bdf1cd678610cb99f19ea83b2d8f43b48b5482 Mon Sep 17 00:00:00 2001 From: Birfy <15378933+Birfy@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:10:04 +0800 Subject: [PATCH] Route a friend's plain face-room line like the owner's: routed, single pass, no follow-ups (#462) --- .../462-friend-line-face-routing.fixed.md | 1 + core/src/host/groups.js | 19 +++-- core/test/rounds.test.js | 66 ++++++++++++++++- docs/subsystems/core-host.md | 2 +- docs/subsystems/social.md | 2 +- patches/ds-bot/0005-social-seams.patch | 70 +++++++++++++++++-- patches/ds-bot/0012-room-membership.patch | 27 +++++-- 7 files changed, 164 insertions(+), 23 deletions(-) create mode 100644 changelog.d/462-friend-line-face-routing.fixed.md diff --git a/changelog.d/462-friend-line-face-routing.fixed.md b/changelog.d/462-friend-line-face-routing.fixed.md new file mode 100644 index 00000000..ffddb81e --- /dev/null +++ b/changelog.d/462-friend-line-face-routing.fixed.md @@ -0,0 +1 @@ +Face-to-face rooms: a friend's plain line is now answered like your own: your Bots decide who answers it (usually one, the Bot that spoke last first), and a Bot's @mention in its answer no longer calls another Bot on, so Bots in two homes cannot set each other off ([#462](https://github.com/ThinkFlowLab/FlyBot/issues/462)). diff --git a/core/src/host/groups.js b/core/src/host/groups.js index f45d895b..dc2f6a0d 100644 --- a/core/src/host/groups.js +++ b/core/src/host/groups.js @@ -533,8 +533,9 @@ export function install(rt) { room.lateRounds = 0 } await rt.save() - // An event post never picks the speakers over a message queued with it. - const opener = posts.findLast(post => !event(post)) ?? posts.at(-1) + // An event post never picks the speakers over a message queued with it; a friend's line that asks + // this home's Bots to answer is a person's line, not an event (#462). + const opener = posts.findLast(post => !event(post) || friendAsks(post)) ?? posts.at(-1) 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 @@ -557,8 +558,7 @@ export function install(rt) { // One pass: a plain line's speakers do not call each other on (#295), so Bots in two homes cannot loop. ...(isPlainFaceLine(room, opener) ? { single: true } : {}), // A person's plain line, the friend's attested one too, is answered once per Bot (#456). - ...(isPlainFaceLine(room, opener) ? { faceLine: openFaceLine(room) } : {}), - ...(!isPlainFaceLine(room, opener) && opener.plain === true && isFaceRoom(room) ? { faceLine: openFaceLine(room, String(opener.human?.authorId ?? '')) } : {}), + ...(isPlainFaceLine(room, opener) ? { faceLine: openFaceLine(room, friendLine(opener) ? String(opener.human.authorId ?? '') : undefined) } : {}), speaking: null, turns: 0, replies: [], @@ -691,12 +691,19 @@ export function install(rt) { // room's order, one at a time (each sees the earlier replies), capped (#295). const plainSpeakers = (members, admin) => (admin ? [admin, ...members.filter(bot => bot !== admin)] : members).slice(0, FACE_PLAIN_SPEAKERS) + // A friend's own typed line, as postHuman posts it (#180, #462): the room's quiet notice sender, carrying the person. + const friendLine = post => post.botId === 'face-notice' && post.human !== undefined + // One that asks this home's Bots to answer: attested plain (or @all), or naming my Bots (#287, #295). + const friendAsks = post => friendLine(post) && (post.plain === true || post.callIds?.length > 0) + // Whether this opener is a person's plain line there: the one that starts a single pass over the // Bots with no Bot-triggered follow-ups inside it (#295). An @ that names a Bot, a Bot's post, a late // reply and the greeting are not. function isPlainFaceLine(room, opener) { - if (!isFaceRoom(room) || opener.botId !== undefined || opener.kickoff || opener.late) return false - if (opener.plain) return true + if (!isFaceRoom(room) || opener.kickoff || opener.late) return false + // A friend's attested plain line (or @all) is a person's line too, routed and single like the owner's (#462). + if (friendLine(opener)) return opener.plain === true + if (opener.botId !== undefined) return false if (opener.quiet || opener.callIds?.length > 0) return false const said = faceMentions(room, opener.text, rt.botOf, { references: (opener.references ?? []).flatMap(reference => reference.labels) }) return !said.all && said.bots.length === 0 && said.friends.length === 0 diff --git a/core/test/rounds.test.js b/core/test/rounds.test.js index 5e7c7caf..fc807283 100644 --- a/core/test/rounds.test.js +++ b/core/test/rounds.test.js @@ -516,11 +516,10 @@ test('#295: a plain face-to-face line lets every local Bot answer in one pass, e const friend = relay(hosted.id, 'friend-plain') const theirs = await turnFor(sent) assert.ok(theirs, 'a friend\'s attested plain line starts my Bots too') + assert.match(theirs.message.content[0].text, /names no Bot/, 'routed like my own plain line (#462)') await answer(theirs.sessionId, theirs.message.source.exchangeId, 'hi Bob', 4) - const other = await turnFor(sent, theirs) - assert.ok(other) - await answer(other.sessionId, other.message.source.exchangeId, 'hi from me too', 5) await friend + assert.equal(sent.filter(entry => entry.message?.source?.role === 'room').length, 1, 'once one of my Bots answered, the other is not asked (#299, #462)') }) test('#295: a plain line in a large face room is cut off at six speakers, the admin first', async (t) => { @@ -956,6 +955,67 @@ test('#299: the first Bot that answers a routed line ends it; a line to everyone await all }) +test('#462: a friend\'s attested plain line is routed and single like my own line; a friend\'s @Bot line is unchanged', async (t) => { + const env = start() + const { emit, relay, answer, stop, sent, rt } = env + t.after(stop) + const { hosted, chief, writer } = await routedRoom(env, 'friend') + // As social.js delivers it: the line is logged first, then posted for the round. + const friendSays = async (eventId, text) => { + rt.applyFaceRoomLine(hosted, { eventId, who: 'Bob', text, authorId: '@bob.home', authorKind: 'human', remote: true }) + await emit(hosted.id, friendPlain(text)) + } + await friendSays('ev-f1', '文档怎么写') + const one = relay(hosted.id, 'friend-1') + const a = await turnFor(sent) + assert.match(promptOf(a), /names no Bot/, 'the friend\'s line is routed: each Bot decides whether it is theirs') + assert.match(promptOf(a), /Default to \[PASS\]/) + await answer(a.sessionId, a.message.source.exchangeId, '[PASS]', 1) + const b = await turnFor(sent, a) + assert.equal(b.sessionId, hosted.sessions[writer]) + // Its @ calls nobody on: one pass, no follow-ups, so Bots in two homes cannot set each other off. + await answer(b.sessionId, b.message.source.exchangeId, 'Start with an outline. @Chief can you check it?', 2) + const first = JSON.parse(await one) + assert.deepEqual(first.replies.filter(reply => reply.kind === undefined).map(reply => reply.text), ['Start with an outline. @Chief can you check it?']) + assert.equal(roomTurns(sent).length, 2, 'the reply\'s @Chief starts no follow-up turn') + + // A follow-up from the friend goes to the Bot that spoke last, and the first answer ends the round. + sent.length = 0 + await friendSays('ev-f2', '那大纲具体怎么写') + const two = relay(hosted.id, 'friend-2') + const c = await turnFor(sent) + assert.equal(c.sessionId, hosted.sessions[writer], 'the last speaker is asked first') + assert.match(promptOf(c), /Writer spoke last before this line/) + await answer(c.sessionId, c.message.source.exchangeId, 'Three headings.', 3) + await two + assert.equal(roomTurns(sent).length, 1, 'the other Bot is not asked once one answered') + + // The friend's line picks the speakers even when a friend's Bot post is queued with it. + sent.length = 0 + await emit(hosted.id, { type: 'user/message', data: { source: { kind: 'bot', role: 'post', senderSessionId: '@chief.bob.home', senderName: "Bob's Chief", remote: { handle: '@chief.bob.home' } }, content: [{ type: 'text', text: 'I can see the whole room.' }] } }) + await friendSays('ev-f3', '有人能帮我订个会议室吗') + const three = relay(hosted.id, 'friend-3') + const d = await turnFor(sent) + assert.ok(d, 'my Bots answer the friend\'s line queued after a Bot post') + assert.match(promptOf(d), /names no Bot/) + await answer(d.sessionId, d.message.source.exchangeId, 'I can book it.', 4) + await three + assert.equal(roomTurns(sent).length, 1) + + // A friend's line that names my Writer (`callIds`) still starts only the Writer, unrouted. + sent.length = 0 + rt.applyFaceRoomLine(hosted, { eventId: 'ev-f4', who: 'Bob', text: '@Writer 你好', authorId: '@bob.home', authorKind: 'human', remote: true }) + await emit(hosted.id, { type: 'user/message', data: { source: { kind: 'bot', role: 'post', senderSessionId: 'face-notice', senderName: 'Bob', quiet: true, notice: true, callIds: [writer], human: { authorId: '@bob.home', name: 'Bob' } }, content: [{ type: 'text', text: '@Writer 你好' }] } }) + const four = relay(hosted.id, 'friend-4') + const e = await turnFor(sent) + assert.equal(e.sessionId, hosted.sessions[writer]) + assert.doesNotMatch(promptOf(e), /names no Bot/) + await answer(e.sessionId, e.message.source.exchangeId, 'Hi Bob', 5) + await four + assert.equal(roomTurns(sent).length, 1) + assert.ok(chief) +}) + // 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) => { diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 534ccd81..5a43f8b3 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -113,7 +113,7 @@ credentials and team mode remain separate work under #55/#198. - `upsertFaceRoom` keeps the six-digit number a room was joined by (`spec.joinCode` / `spec.code`) on `room.face.joinCode`, and the auto title uses it whenever a later upsert (a poll, a leave) does not repeat it, so the title never falls back from the number to a date (#304). - 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. 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`; lines queued together by the newest one) 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. **One answer per Bot per line (#456):** a person's plain line (the owner's, opened by `drainRoomInbox` or the opener; a friend's attested `plain` line once it is in the log) opens `room.faceLine` (`seq`, `answered`, `asked` exchange ids); a Bot already in `answered` is passed without a model call, a second answer from the same round is dropped, and `lateRoomReply` drops a late answer from a turn asked for that line when the Bot already answered it. A newer person line ends it. **Held Bot lines (#456):** `fanoutRoomLine` records a local Bot's face line in `room.heldLines` (`seq`, `botId`, `text`, `status: sending`, capped at 50) before the transport sends it; a transport answer `{ held: true, approvalId }` makes it `pending`, anything else drops the record. `rt.settleHeldLine(approvalId, decision)` (`flybot.rooms.settleHeld`) removes it on approve and marks it `rejected` on reject; it is saved with the room, and the roster view carries `heldLines` (not `faceLine`). 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`; lines queued together by the newest one) when it starts the round; a friend's attested plain line routes at the opener the same way (#462): `postHuman` posts it from the quiet `face-notice` sender with `human`, and `isPlainFaceLine` reads such a line by its `plain` flag (`friendLine`), so it gets `route`, `single` and the last speaker first; a friend's line that asks my Bots (`plain` or `callIds`, `friendAsks`) picks the speakers over a Bot post queued with it, while their `@Bot` line (`callIds`) stays unrouted. 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. **One answer per Bot per line (#456):** a person's plain line (the owner's, opened by `drainRoomInbox` or the opener; a friend's attested `plain` line once it is in the log) opens `room.faceLine` (`seq`, `answered`, `asked` exchange ids); a Bot already in `answered` is passed without a model call, a second answer from the same round is dropped, and `lateRoomReply` drops a late answer from a turn asked for that line when the Bot already answered it. A newer person line ends it. **Held Bot lines (#456):** `fanoutRoomLine` records a local Bot's face line in `room.heldLines` (`seq`, `botId`, `text`, `status: sending`, capped at 50) before the transport sends it; a transport answer `{ held: true, approvalId }` makes it `pending`, anything else drops the record. `rt.settleHeldLine(approvalId, decision)` (`flybot.rooms.settleHeld`) removes it on approve and marks it `rejected` on reject; it is saved with the room, and the roster view carries `heldLines` (not `faceLine`). Covered by `core/test/rounds.test.js`, `core/test/face-mentions.test.js` and `plugins/social/test/social.test.js`. - New Bot model (#410): `roster.js` `createBot` fixes a Bot's model at creation. A Bot made by another Bot (`create_bot`, a tree child via `flybot.bot.create` with `createdBy`) and given no model starts on its maker's effective model (`desiredModel`) and its thinking level resolved against that model (dropped when that model does not serve it), before the kickoff, so its first request never goes to an unconfigured profile default. A model the caller names wins; a Bot the user makes in the UI keeps the client's start model (Main Bot's, #230). Covered by `core/test/roster.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`. diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index dfca6665..0a871ff8 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -93,7 +93,7 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ ### Face-room human turns and UI polish (#166, #167) -- A plain human line in an open face-to-face room is logged and fanned out once, and starts a single pass of every local Bot (admin first, capped at 6, no @ needed; #287, #295; core `openingSpeakers`, see [core-host](core-host.md)). Since #299 the Bots pick the responder from a plain line (they may `[PASS]`; the first Bot that answers ends the round unless the line is to everyone; the first Bot answers if all pass on the owner's own line), and a line to everyone or `@all` is unchanged. A friend's attested plain line (or `@all`) starts the same pass on this home (the plugin passes `attested: true` to `postHuman` only for a stamped line); an attested line that names Bots starts only the Bot(s) it names by the label the picker shows (`Friend's Chief`) or by handle; `plugins/social/test` has the two-home case. **Explicit mention list (#293):** the sender's host resolves the picker's labels against the room (`faceMentions().handles`; core `onFaceLine(fn)` passes `{ mentions }` for the owner's typed line) and `faceApi.send` puts the handles in `mentions` next to `typed_by_owner` only when `GET /v1/platform` `features` lists `mentions` (the one feature probe, `serverMentions()`); never on the legacy envelope, Bot lines or an older server. The receiver reads `mentions` (`cleanMentions`, at most 8 handles) only from a line the server attests `typed_by_owner` (`renderFaceEvent`), and `postHuman` resolves the handles against the room first (`mentionedByHandle`): only my own Bots in the list answer; a list naming nobody the room knows, or no list, falls back to the label/name reading above. The composer's `@` picker inserts the label text, so the handles are derived from it at send, not tracked in the composer. +- A plain human line in an open face-to-face room is logged and fanned out once, and starts a single pass of every local Bot (admin first, capped at 6, no @ needed; #287, #295; core `openingSpeakers`, see [core-host](core-host.md)). Since #299 the Bots pick the responder from a plain line (they may `[PASS]`; the first Bot that answers ends the round unless the line is to everyone; the first Bot answers if all pass on the owner's own line), and a line to everyone or `@all` is unchanged. A friend's attested plain line (or `@all`) starts the same pass on this home, routed and single like the owner's own line (#462) (the plugin passes `attested: true` to `postHuman` only for a stamped line); an attested line that names Bots starts only the Bot(s) it names by the label the picker shows (`Friend's Chief`) or by handle; `plugins/social/test` has the two-home case. **Explicit mention list (#293):** the sender's host resolves the picker's labels against the room (`faceMentions().handles`; core `onFaceLine(fn)` passes `{ mentions }` for the owner's typed line) and `faceApi.send` puts the handles in `mentions` next to `typed_by_owner` only when `GET /v1/platform` `features` lists `mentions` (the one feature probe, `serverMentions()`); never on the legacy envelope, Bot lines or an older server. The receiver reads `mentions` (`cleanMentions`, at most 8 handles) only from a line the server attests `typed_by_owner` (`renderFaceEvent`), and `postHuman` resolves the handles against the room first (`mentionedByHandle`): only my own Bots in the list answer; a list naming nobody the room knows, or no list, falls back to the label/name reading above. The composer's `@` picker inserts the label text, so the handles are derived from it at send, not tracked in the composer. - A held room line belongs to one room (#188): the send path records `settings.holdRooms[approvalId] = faceRoomId` when Bot Social's card does not name the room, and `pendingApprovals` drops holds with no known room (legacy) or whose room is left / closed / past here, so no other room shows the card and no waiting dot stays on an old row. The room scope in the client, the `roomApprovals` provider and `hidden-remotes` match the room id strictly. - Face-room holds (`face_room_message` / `roomId`) attach `pendingCount` to that room only. They do not badge an unrelated friend row via `from`/`to` handle. The social strip no longer draws an **N to approve** tag under the last sidebar row; the gold wait dot on the owning RoomRow / 1:1 remains. - Approval preview strips `To face room:` / `To the room:` and wraps on word boundaries (`overflow-wrap:break-word; word-break:normal`), with **Show more** when long. diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index ce5d58fe..1e061351 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -841,7 +841,7 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js test('a plain message steered mid-round is answered by everyone, admin first, inside the round', async (t) => { const { call, emit, userMessage, compose, relay, inboxOf, answer, stop, sent } = start() t.after(stop) -@@ -331,6 +331,378 @@ test('a stopped round leaves a waiting message for the next round', async (t) => +@@ -331,6 +331,377 @@ test('a stopped round leaves a waiting message for the next round', async (t) => assert.equal(inboxOf(room.id).nextTurn.length, 1) }) @@ -1018,11 +1018,10 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js + const friend = relay(hosted.id, 'friend-plain') + const theirs = await turnFor(sent) + assert.ok(theirs, 'a friend\'s attested plain line starts my Bots too') ++ assert.match(theirs.message.content[0].text, /names no Bot/, 'routed like my own plain line (#462)') + await answer(theirs.sessionId, theirs.message.source.exchangeId, 'hi Bob', 4) -+ const other = await turnFor(sent, theirs) -+ assert.ok(other) -+ await answer(other.sessionId, other.message.source.exchangeId, 'hi from me too', 5) + await friend ++ assert.equal(sent.filter(entry => entry.message?.source?.role === 'room').length, 1, 'once one of my Bots answered, the other is not asked (#299, #462)') +}) + +test('#295: a plain line in a large face room is cut off at six speakers, the admin first', async (t) => { @@ -1220,7 +1219,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,293 @@ test('a message sent between rounds still opens a full round', async (t) => { +@@ -347,3 +347,354 @@ 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']) }) @@ -1449,6 +1448,67 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js + await all +}) + ++test('#462: a friend\'s attested plain line is routed and single like my own line; a friend\'s @Bot line is unchanged', async (t) => { ++ const env = start() ++ const { emit, relay, answer, stop, sent, rt } = env ++ t.after(stop) ++ const { hosted, chief, writer } = await routedRoom(env, 'friend') ++ // As social.js delivers it: the line is logged first, then posted for the round. ++ const friendSays = async (eventId, text) => { ++ rt.applyFaceRoomLine(hosted, { eventId, who: 'Bob', text, authorId: '@bob.home', authorKind: 'human', remote: true }) ++ await emit(hosted.id, friendPlain(text)) ++ } ++ await friendSays('ev-f1', '文档怎么写') ++ const one = relay(hosted.id, 'friend-1') ++ const a = await turnFor(sent) ++ assert.match(promptOf(a), /names no Bot/, 'the friend\'s line is routed: each Bot decides whether it is theirs') ++ assert.match(promptOf(a), /Default to \[PASS\]/) ++ await answer(a.sessionId, a.message.source.exchangeId, '[PASS]', 1) ++ const b = await turnFor(sent, a) ++ assert.equal(b.sessionId, hosted.sessions[writer]) ++ // Its @ calls nobody on: one pass, no follow-ups, so Bots in two homes cannot set each other off. ++ await answer(b.sessionId, b.message.source.exchangeId, 'Start with an outline. @Chief can you check it?', 2) ++ const first = JSON.parse(await one) ++ assert.deepEqual(first.replies.filter(reply => reply.kind === undefined).map(reply => reply.text), ['Start with an outline. @Chief can you check it?']) ++ assert.equal(roomTurns(sent).length, 2, 'the reply\'s @Chief starts no follow-up turn') ++ ++ // A follow-up from the friend goes to the Bot that spoke last, and the first answer ends the round. ++ sent.length = 0 ++ await friendSays('ev-f2', '那大纲具体怎么写') ++ const two = relay(hosted.id, 'friend-2') ++ const c = await turnFor(sent) ++ assert.equal(c.sessionId, hosted.sessions[writer], 'the last speaker is asked first') ++ assert.match(promptOf(c), /Writer spoke last before this line/) ++ await answer(c.sessionId, c.message.source.exchangeId, 'Three headings.', 3) ++ await two ++ assert.equal(roomTurns(sent).length, 1, 'the other Bot is not asked once one answered') ++ ++ // The friend's line picks the speakers even when a friend's Bot post is queued with it. ++ sent.length = 0 ++ await emit(hosted.id, { type: 'user/message', data: { source: { kind: 'bot', role: 'post', senderSessionId: '@chief.bob.home', senderName: "Bob's Chief", remote: { handle: '@chief.bob.home' } }, content: [{ type: 'text', text: 'I can see the whole room.' }] } }) ++ await friendSays('ev-f3', '有人能帮我订个会议室吗') ++ const three = relay(hosted.id, 'friend-3') ++ const d = await turnFor(sent) ++ assert.ok(d, 'my Bots answer the friend\'s line queued after a Bot post') ++ assert.match(promptOf(d), /names no Bot/) ++ await answer(d.sessionId, d.message.source.exchangeId, 'I can book it.', 4) ++ await three ++ assert.equal(roomTurns(sent).length, 1) ++ ++ // A friend's line that names my Writer (`callIds`) still starts only the Writer, unrouted. ++ sent.length = 0 ++ rt.applyFaceRoomLine(hosted, { eventId: 'ev-f4', who: 'Bob', text: '@Writer 你好', authorId: '@bob.home', authorKind: 'human', remote: true }) ++ await emit(hosted.id, { type: 'user/message', data: { source: { kind: 'bot', role: 'post', senderSessionId: 'face-notice', senderName: 'Bob', quiet: true, notice: true, callIds: [writer], human: { authorId: '@bob.home', name: 'Bob' } }, content: [{ type: 'text', text: '@Writer 你好' }] } }) ++ const four = relay(hosted.id, 'friend-4') ++ const e = await turnFor(sent) ++ assert.equal(e.sessionId, hosted.sessions[writer]) ++ assert.doesNotMatch(promptOf(e), /names no Bot/) ++ await answer(e.sessionId, e.message.source.exchangeId, 'Hi Bob', 5) ++ await four ++ assert.equal(roomTurns(sent).length, 1) ++ assert.ok(chief) ++}) ++ +// 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) => { diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index c978bd10..af3a3727 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -1021,7 +1021,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js } } -@@ -400,13 +400,18 @@ export function install(rt) { +@@ -400,35 +400,50 @@ export function install(rt) { // The opening greeting is a cue for the admin, not a line of the group; a member's // late failure, its work picked up after a restart, or its work the user stopped is // an event of the round. @@ -1044,7 +1044,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,27 @@ export function install(rt) { + room.lateRounds = 0 + } + await rt.save() +- // An event post never picks the speakers over a message queued with it. +- const opener = posts.findLast(post => !event(post)) ?? posts.at(-1) ++ // An event post never picks the speakers over a message queued with it; a friend's line that asks ++ // this home's Bots to answer is a person's line, not an event (#462). ++ const opener = posts.findLast(post => !event(post) || friendAsks(post)) ?? posts.at(-1) 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 @@ -1068,8 +1075,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js + // One pass: a plain line's speakers do not call each other on (#295), so Bots in two homes cannot loop. + ...(isPlainFaceLine(room, opener) ? { single: true } : {}), + // A person's plain line, the friend's attested one too, is answered once per Bot (#456). -+ ...(isPlainFaceLine(room, opener) ? { faceLine: openFaceLine(room) } : {}), -+ ...(!isPlainFaceLine(room, opener) && opener.plain === true && isFaceRoom(room) ? { faceLine: openFaceLine(room, String(opener.human?.authorId ?? '')) } : {}), ++ ...(isPlainFaceLine(room, opener) ? { faceLine: openFaceLine(room, friendLine(opener) ? String(opener.human.authorId ?? '') : undefined) } : {}), speaking: null, turns: 0, replies: [], @@ -1114,7 +1120,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js return lines.length === 0 ? `"${room.name}" has no messages yet.` : `Latest in "${room.name}" (oldest first):\n${transcript(lines)}` }, })) -@@ -548,25 +548,64 @@ export function install(rt) { +@@ -548,25 +548,71 @@ export function install(rt) { // the admin answers alone in admin mode, or first of everyone. What a member posts // after its round moved on is answered by the member that asked for it; work the // user asked for is left for the user to read. @@ -1122,12 +1128,19 @@ diff --git a/src/host/groups.js b/src/host/groups.js + // room's order, one at a time (each sees the earlier replies), capped (#295). + const plainSpeakers = (members, admin) => (admin ? [admin, ...members.filter(bot => bot !== admin)] : members).slice(0, FACE_PLAIN_SPEAKERS) + ++ // A friend's own typed line, as postHuman posts it (#180, #462): the room's quiet notice sender, carrying the person. ++ const friendLine = post => post.botId === 'face-notice' && post.human !== undefined ++ // One that asks this home's Bots to answer: attested plain (or @all), or naming my Bots (#287, #295). ++ const friendAsks = post => friendLine(post) && (post.plain === true || post.callIds?.length > 0) ++ + // Whether this opener is a person's plain line there: the one that starts a single pass over the + // Bots with no Bot-triggered follow-ups inside it (#295). An @ that names a Bot, a Bot's post, a late + // reply and the greeting are not. + function isPlainFaceLine(room, opener) { -+ if (!isFaceRoom(room) || opener.botId !== undefined || opener.kickoff || opener.late) return false -+ if (opener.plain) return true ++ if (!isFaceRoom(room) || opener.kickoff || opener.late) return false ++ // A friend's attested plain line (or @all) is a person's line too, routed and single like the owner's (#462). ++ if (friendLine(opener)) return opener.plain === true ++ if (opener.botId !== undefined) return false + if (opener.quiet || opener.callIds?.length > 0) return false + const said = faceMentions(room, opener.text, rt.botOf, { references: (opener.references ?? []).flatMap(reference => reference.labels) }) + return !said.all && said.bots.length === 0 && said.friends.length === 0