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/462-friend-line-face-routing.fixed.md
Original file line number Diff line number Diff line change
@@ -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)).
19 changes: 13 additions & 6 deletions core/src/host/groups.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: [],
Expand Down Expand Up @@ -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
Expand Down
66 changes: 63 additions & 3 deletions core/test/rounds.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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) => {
Expand Down
2 changes: 1 addition & 1 deletion docs/subsystems/core-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
Loading
Loading