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/299-route-live-drain.fixed.md
Original file line number Diff line number Diff line change
@@ -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)
38 changes: 30 additions & 8 deletions core/src/host/groups.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Route batched inbox lines instead of disabling routing

When two owner messages are queued before the notice-triggered relay reaches drainRoomInbox, waitingLines() returns both, so this predicate makes starting false. The loop then schedules openingSpeakers without setting route or single, allowing every local Bot to answer and leaving the reported live-path regression unfixed for rapid consecutive sends. Route the batch—at least according to its newest line, matching group_relay semantics—instead of requiring exactly one queued line.

Useful? React with 👍 / 👎.

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
Expand All @@ -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')
Expand Down Expand Up @@ -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,
Expand Down
48 changes: 48 additions & 0 deletions core/test/rounds.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
2 changes: 1 addition & 1 deletion docs/subsystems/core-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 49 additions & 1 deletion patches/ds-bot/0005-social-seams.patch
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
})
Expand Down Expand Up @@ -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)
+})
Loading
Loading