diff --git a/changelog.d/913-report-addressed-silence.fixed.md b/changelog.d/913-report-addressed-silence.fixed.md new file mode 100644 index 000000000..9015179ff --- /dev/null +++ b/changelog.d/913-report-addressed-silence.fixed.md @@ -0,0 +1 @@ +A group round's closing report no longer folds a member that was addressed with `@` and never answered into the same sentence as a member that read the line and passed: the two now get separate sentences, so "he had nothing to add" can be told from "he was called and said nothing". A member the line did not name still appears under `Stayed silent:` ([#913](https://github.com/ThinkFlowLab/FlyBot/issues/913)). diff --git a/core/src/host/groups.js b/core/src/host/groups.js index 0cd0a38dc..39cc2381a 100644 --- a/core/src/host/groups.js +++ b/core/src/host/groups.js @@ -404,6 +404,8 @@ export function install(rt) { } } callMembers(round, turns, index - 1, speakers, note, ROOM_ADMIN_TURNS_EACH) + // The line names them, so the report says "no answer" rather than "stayed silent" (#913). + for (const slot of round.slots) if (slot.by === undefined && found.some(bot => bot.id === slot.id)) slot.addressed = true } // The user spoke in this room, so late replies may be answered again. room.lateRounds = 0 @@ -617,8 +619,9 @@ export function install(rt) { // 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 + // The members the opening line names: a Bot's post calls them to work for that Bot, a person's + // line addresses them. The report keeps that apart from a plain pass (#913). + const named = mentionsIn(opener.text ?? '', room.members.map(rt.botOf).filter(Boolean), opener.references ?? []).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 route = routeFor(room, opener, startSeq) @@ -632,7 +635,7 @@ export function install(rt) { ...(references.length > 0 ? { references, shown: [] } : {}), slots: speakers.map(member => ({ id: member.id, name: member.name, status: 'waiting', - ...(named.includes(member) ? { by: opener.botId } : {}), + ...(named.includes(member) ? (opener.botId === undefined ? { addressed: true } : { by: opener.botId }) : {}), })), // 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 } : {}), @@ -998,11 +1001,22 @@ export function install(rt) { const lines = round.replies.filter(reply => reply.kind === undefined).map(reply => `${reply.name}: ${reply.text}`) const waiting = round.replies.filter(reply => reply.kind === 'approval').map(reply => reply.name) const working = round.replies.filter(reply => reply.kind === 'working').map(reply => reply.name) + // A turn that ended without a line: a pass, or no reply at all. A member the round's line named + // and that ended this way was addressed and did not answer — not the same thing as a member that + // read the line and had nothing to add (#913). `addressed` is the person's line, `by` the member + // that called it; the poster's own call is the "your @" one. + const silentTurn = slot => slot.status === 'passed' || slot.status === 'missed' + const mine = slot => slot.addressed === true || (slot.by !== undefined && slot.by === post.botId) + const addressed = round.slots.filter(slot => mine(slot) && silentTurn(slot)).map(slot => slot.name) + const called = round.slots.filter(slot => !mine(slot) && slot.by !== undefined && silentTurn(slot)).map(slot => slot.name) + const silent = round.passed.filter(name => !addressed.includes(name) && !called.includes(name)) const text = [ lines.length > 0 ? `Replies to your post (oldest first):\n${lines.join('\n')}` : 'Nobody replied to your post.', working.length > 0 ? `Still working on it: ${working.join(', ')}. Each posts its result in the group when done, not to you; read_group_chat shows it.` : '', waiting.length > 0 ? `Waiting for the user's approval: ${waiting.join(', ')}. What they say afterwards is posted in the group, not sent to you; read_group_chat shows it.` : '', - round.passed.length > 0 ? `Stayed silent: ${round.passed.join(', ')}.` : '', + silent.length > 0 ? `Stayed silent: ${silent.join(', ')}.` : '', + addressed.length > 0 ? `No answer to your @: ${addressed.join(', ')}.` : '', + called.length > 0 ? `Called, but no answer: ${called.join(', ')}.` : '', round.failed.length > 0 ? `Could not answer because their model call failed: ${round.replies.filter(reply => reply.kind === 'failed').map(reply => `${reply.name} (${failureLine({ code: reply.code, status: reply.status, message: reply.text })})`).join(', ')}.` : '', ].filter(Boolean).join('\n\n') // The replies are what the room's members said, so the report is the owner's request only when the diff --git a/core/test/rounds.test.js b/core/test/rounds.test.js index e36ba4101..2f1791566 100644 --- a/core/test/rounds.test.js +++ b/core/test/rounds.test.js @@ -1603,3 +1603,70 @@ test('a face room\'s roster and routing rule stand in its section; a routed turn } assert.ok(turns.some(turn => turn.sessionId === hosted.sessions[writer])) }) + +// #913: the closing report wrote a member the line called with `@` and that never answered into the +// same sentence as a member that read the line and answered [PASS], so the person could not tell "he +// had nothing to add" from "he was addressed and said nothing". The round already knows which of its +// slots the line addressed; the report now says so on its own sentence and leaves the rest where it was. +test('#913: a called member that never answers is reported apart from a member that merely stayed silent', async (t) => { + const { call, emit, compose, relay, answer, stop, sent } = start() + t.after(stop) + const room = await team(call, sent) + const chief = (await call('state', {})).value.mainBotId + const editor = (await call('create-bot', { name: 'Editor' })).value + await call('update-room', { id: room.id, add: [editor.id] }) + sent.length = 0 + // A Bot posts with report_back, so the round's closing report comes back to it. The post names + // nobody, so the room's order answers it: the Writer first, the Editor next. + const post = async (text) => emit(room.id, { type: 'user/message', data: { source: { kind: 'bot', role: 'post', senderSessionId: chief, senderName: 'Chief', reportBack: true }, content: [{ type: 'text', text }] } }) + const reportAfter = async (at) => waitFor(() => sent.filter(entry => entry.message?.source?.role === 'report')[at]) + + // Round one: the Writer passes without being called; the Editor is called with `@` and passes too. + await post('please check the draft') + const running = relay(room.id, 'r913-1') + const first = await turnFor(sent) + await compose(room.id, '@Editor ping') + await answer(first.sessionId, first.message.source.exchangeId, '[PASS]', 1) + const second = await turnFor(sent, first) + assert.match(second.message.content[0].text, /the user mentioned you/) + await answer(second.sessionId, second.message.source.exchangeId, '[PASS]', 2) + const done = JSON.parse(await running) + assert.deepEqual(done.passed, ['Writer', 'Editor'], 'both turns ended without a line') + const report = await reportAfter(0) + assert.ok(report, 'the round reported back to the Bot that posted') + const text = report.message.content[0].text + assert.match(text, /Stayed silent: Writer\./, 'a pass nobody called stays in that sentence') + assert.match(text, /No answer to your @: Editor\./, 'a called member that did not answer gets its own sentence') + assert.doesNotMatch(text, /Stayed silent: [^.]*Editor/, 'the two outcomes are never written into one sentence') + + // Round two: the same call, answered. No "no answer" sentence at all. + await post('one more look') + const running2 = relay(room.id, 'r913-2') + const third = await turnFor(sent, first, second) + await compose(room.id, '@Editor ping') + await answer(third.sessionId, third.message.source.exchangeId, '[PASS]', 3) + const fourth = await turnFor(sent, first, second, third) + assert.match(fourth.message.content[0].text, /the user mentioned you/) + await answer(fourth.sessionId, fourth.message.source.exchangeId, 'Here it is.', 4) + await running2 + const text2 = (await reportAfter(1)).message.content[0].text + assert.match(text2, /Stayed silent: Writer\./) + assert.doesNotMatch(text2, /No answer to your @/, 'a called member that answered is not reported as missing') + + // Round three: the called member's turn ends with no reply at all (missed, not a passing string). + // It lands on the same sentence as a pass, once, and never twice. + await post('last one') + const controller = new AbortController() + const running3 = relay(room.id, 'r913-3', controller.signal) + const fifth = await turnFor(sent, first, second, third, fourth) + await compose(room.id, '@Editor ping') + await answer(fifth.sessionId, fifth.message.source.exchangeId, '[PASS]', 5) + const sixth = await turnFor(sent, first, second, third, fourth, fifth) + assert.match(sixth.message.content[0].text, /the user mentioned you/) + controller.abort() + await running3.catch(() => {}) + const text3 = (await reportAfter(2)).message.content[0].text + assert.match(text3, /No answer to your @: Editor\./, 'a turn that ended with no reply is reported the same way') + assert.equal((text3.match(/No answer to your @/g) ?? []).length, 1, 'reported once, not twice') + assert.match(text3, /Stayed silent: Writer\./) +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index da34a3788..67c874647 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -71,6 +71,10 @@ A member's group Session reads the group's fixed rules from a system-prompt sect `post_to_group` has no cap on the rounds Bots start in one group between the user's lines. The old `ROOM_BOT_ROUNDS` (3) gate was turned off by owner decision 2026-10-21 07:34 Asia/Shanghai: it refused a recurring scheduled task's fourth post with a tool result only, so owner-set runs stopped overnight with nothing shown in the group. A saved room may still carry `botRounds`; nothing reads it, and `channel.js` keeps it out of the roster. The guards that stay: `MAX_HOPS` (6) ends a Bot-to-Bot chain (`message_bot`, and `post_to_group` from a relayed turn); `MAX_UNREAD_REQUESTS` (10) per Bot chat; `ROOM_TURNS_EACH` / `ROOM_ADMIN_TURNS_EACH` (2 / 4) per member per round, except the room's admin when a member names it with `@`: that call is never dropped by the cap (#804), because it is a report to the coordinator rather than a chain, and the round's own turn limit still bounds the round; `ROOM_LATE_ROUNDS` (10) late replies answered before the user speaks again; and a Bot cannot `post_to_group` into the room it is speaking in. Face-to-face rooms take the same path. Covered by `core/test/rounds.test.js`. +### A group round's closing report (#913) + +When a Bot posts with `report_back`, `reportRound` sends it the round's closing report. A member the round's line named and that ended its turn without a line (a pass, or no reply at all) was addressed and did not answer, which is not the same thing as a member that read the line and had nothing to add: the first gets `No answer to your @: .` (the poster's own line or a person's line named it; another member's call is `Called, but no answer: .`), and only the rest stays under `Stayed silent:`. A slot keeps `addressed: true` when a person's line named it, and `by: ` when a Bot's post did, so the report reads the slots instead of `round.passed` alone. Nothing is re-called: the everyone-passed fallback (`fallbackDue`) keeps its single extra turn, and the member prompt is unchanged. Covered by `core/test/rounds.test.js`. + ### Team state and access - The saved team (`state.json`) is versioned. Version 2 (#47) carries the identity schema — `members` records, a per-Bot `ownerId`, the owner's `designatedChiefId`. A legacy version-1 file is migrated on load with a one-time `state.json.v1` backup; an existing backup counts only when it is a readable file holding a parseable version-1 state, and anything else (or any malformed ownership) fails closed (read-only). An older binary refuses a newer version read-only. This is schema foundation only — team mode stays disabled, see [team](team.md). On top of it, member auth (#48) adds host module `src/host/member-auth.js`, installed after `store` in `MODULES` (`core/index.js`): `invitations` and `authSessions` maps live on state (no `STATE_VERSION` bump), the channel resolves the actor from the `x-flybot-member-session` header, and endpoints are `auth-invite` / `auth-redeem` / `auth-revoke` / `auth-whoami` / `auth-remove-member` / `auth-revoke-invite` with `body.actorId` ignored; `auth-invite` alone also opens for an anonymous caller on a host with no sessions yet (bootstrap first invite), `auth-revoke-invite` kills an unredeemed invitation (redeeming it afterwards fails with `revoked`), `removeMember` refuses a member that still owns a Bot (`has_bots`), and the auth mutations are serialized behind an in-process lock so concurrent `removeMember` calls cannot both pass the last-owner check. Sign-in surface only — still not team mode; details in [team](team.md). On top of it, owner-scoped access (#49) adds host module `src/host/authz.js`, installed after member-auth: when any `authSessions` exist, the channel `authorize()` default-denies cross-owner Bot/room/session access, blocks shared non-isolatable resources (secrets, connectors, folder picks, browser bridge, update-install, diagnostics, set-prefs, `add-agent` — the DSH Agent is one per deployment and carries no owner) with `bot/blocked-shared` when more than one member exists, stamps `create-bot` with `actor.memberId`, and filters `view()`/`live()` to the actor; with no sessions the single-human behavior is unchanged. Session targets resolve through the store's owner maps — a Bot id via `botOf`, current chats and group Sessions and earlier parts via `selfOf`/`groupOwners`/`pastOwners` (a shared room Session also resolves for its current participants, #50) — and unresolvable ids deny. `approval-answer` gates on the approval's own Bot or room (resolved by approval id), never on caller-supplied session fields. `flybot/*` plugin endpoints (src/host/hooks.js) reach any signed-in actor for reads; plugin writes follow the shared rule. `view()`/`live()` narrowing (`rt.ownedView`/`rt.ownedLive` in authz.js) keeps per-session entries (`questions`, `exchangeTurns`) the actor owns, and deployment-wide surfaces (agents, secrets, connectors, memory reviews) only while a single member exists — the same rule as the shared writes; rooms the actor belongs to and the Bots in them (#50) stay visible. `create-room` refuses a request with neither a member Bot nor any participant. On top of it, room membership (#50) adds host module `src/host/room-membership.js`, installed after groups: a room may carry typed `participants` (`human` / local `bot` / granted remote `bot` with `handle` + owner fields, roles `admin` / `member`, `joinedSeq` / `leftSeq`); log lines carry `authorId` + `authorKind`; history is the membership window (current members and the owner of an invited Bot see `seq >=` their interval; leave withdraws access; rejoin starts a new interval); local cross-owner rooms accept each side's *granted* Bots (#90: default deny, Chief included); a friend's Bot from `social/remote-bots` (`cleanRemote`) may be added and ungranted Bots stay private, and a room with a friend's Bot holds only granted local Bots (a Bot that joined by room code, `viaCode`, only for that room); `view()` labels remotes with an owner badge and shows an invited owner **Shared room from \**. Face-to-face rooms (#83) reuse the same `participants` / membership-window / owner-badge presentation with `origin: 'face-to-face'`: room-only remotes may sit in the room without a person-grant or a friendship, `presentRoom` treats the viewer as a member (not Shared room from), leave skips the two-Bot floor, and `fanoutRoomLine` goes to `rt.faceRoomFanout` instead of friend `flybotSend`. Still not team mode — see [team](team.md). Per-Bot grants (#90) add host module `src/host/reach.js`, installed after room-membership: `state.grants[botId]` is the list of independent grants (one Bot → one grantee, optionally room-scoped); default deny, Chief included; a Bot whose owner is not a member has none. Grants are set with `rt.setGrants`, exposed as `BotView.granted`, `flybot.bot.hasGrant` / `hasAnyGrant`, `flybot.grants` and the `bot.grants` event. `reach()` is the owner policy: a friend's Bot may reach only local Bots granted to that sender, only a granted Bot (or one code-joined in that room) may message a friend's Bot, another owner's Bot is reached only if both sides have matching grants or both are members of the room the message belongs to. It is not a middleware: hooks.js runs it before the `bot.send` chain, again on approval release, and on every deliver with a sender or remote origin (resumed, retried, deferred, re-sent), so no verdict or `bypass` can skip it and a queued message is judged by what is true when it is delivered. Revoking a grant, or replacing its Chief, removes it from rooms that have friends' Bots or several owners at once (`rt.evictUnshared`). Approval binding (#52) completes the held-send half of this: `state.flybot.approvals` records carry `requestId`, a sha256 `version` over the request content, server-derived `ownerId` and `origin` (`via`/`hop`/audience) — claims in the message (`meta`, text) never confer authority. `flybot/decide` is allowed through authz for any signed-in actor and the per-record check happens in hooks (`decide` requires the sender Bot's owner's session, `bot/forbidden` otherwise, `bot/unauthorized` without a session); `release()` revalidates before dispatch and marks the record `stale` when the request changed, the sender or target Bot is gone (also invalidated eagerly on `deleteBot`), or the Bot changed owners; a demoted Chief's sender-side holds are invalidated eagerly on `setMain` (not inside `staleReason`), and `release()` re-checks immediately before dispatch so an in-flight approve cannot go out after a concurrent invalidation. Unknown `invalidateFor` field values throw. `flybot/approvals` lists only the actor's own holds while enforcement is on; decided stale records keep their attribution in state but appear in no member's list. Which statuses still wait for that decision is one rule in `src/shared/approval-waiting.js` (#754): `decide()`, `heldOf()` in the chat view, the in-flight filter that invalidates holds, `PENDING_MAX` and the `APPROVALS_KEPT` cleanup all ask `isApprovalWaiting()`, and `channel.js` counts a row's waiting replies with it, so a `pending_approval` hold (Bot Social's own spelling) is decidable, drawn as waiting and never dropped mid-wait. diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 582d77919..e81b5fee5 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -1560,7 +1560,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,893 @@ test('a message sent between rounds still opens a full round', async (t) => { +@@ -347,3 +347,960 @@ const result = JSON.parse(await running) assert.deepEqual(result.replies.map(reply => reply.name ?? 'User'), ['Chief', 'Writer']) }) @@ -2454,3 +2454,70 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js + } + assert.ok(turns.some(turn => turn.sessionId === hosted.sessions[writer])) +}) ++ ++// #913: the closing report wrote a member the line called with `@` and that never answered into the ++// same sentence as a member that read the line and answered [PASS], so the person could not tell "he ++// had nothing to add" from "he was addressed and said nothing". The round already knows which of its ++// slots the line addressed; the report now says so on its own sentence and leaves the rest where it was. ++test('#913: a called member that never answers is reported apart from a member that merely stayed silent', async (t) => { ++ const { call, emit, compose, relay, answer, stop, sent } = start() ++ t.after(stop) ++ const room = await team(call, sent) ++ const chief = (await call('state', {})).value.mainBotId ++ const editor = (await call('create-bot', { name: 'Editor' })).value ++ await call('update-room', { id: room.id, add: [editor.id] }) ++ sent.length = 0 ++ // A Bot posts with report_back, so the round's closing report comes back to it. The post names ++ // nobody, so the room's order answers it: the Writer first, the Editor next. ++ const post = async (text) => emit(room.id, { type: 'user/message', data: { source: { kind: 'bot', role: 'post', senderSessionId: chief, senderName: 'Chief', reportBack: true }, content: [{ type: 'text', text }] } }) ++ const reportAfter = async (at) => waitFor(() => sent.filter(entry => entry.message?.source?.role === 'report')[at]) ++ ++ // Round one: the Writer passes without being called; the Editor is called with `@` and passes too. ++ await post('please check the draft') ++ const running = relay(room.id, 'r913-1') ++ const first = await turnFor(sent) ++ await compose(room.id, '@Editor ping') ++ await answer(first.sessionId, first.message.source.exchangeId, '[PASS]', 1) ++ const second = await turnFor(sent, first) ++ assert.match(second.message.content[0].text, /the user mentioned you/) ++ await answer(second.sessionId, second.message.source.exchangeId, '[PASS]', 2) ++ const done = JSON.parse(await running) ++ assert.deepEqual(done.passed, ['Writer', 'Editor'], 'both turns ended without a line') ++ const report = await reportAfter(0) ++ assert.ok(report, 'the round reported back to the Bot that posted') ++ const text = report.message.content[0].text ++ assert.match(text, /Stayed silent: Writer\./, 'a pass nobody called stays in that sentence') ++ assert.match(text, /No answer to your @: Editor\./, 'a called member that did not answer gets its own sentence') ++ assert.doesNotMatch(text, /Stayed silent: [^.]*Editor/, 'the two outcomes are never written into one sentence') ++ ++ // Round two: the same call, answered. No "no answer" sentence at all. ++ await post('one more look') ++ const running2 = relay(room.id, 'r913-2') ++ const third = await turnFor(sent, first, second) ++ await compose(room.id, '@Editor ping') ++ await answer(third.sessionId, third.message.source.exchangeId, '[PASS]', 3) ++ const fourth = await turnFor(sent, first, second, third) ++ assert.match(fourth.message.content[0].text, /the user mentioned you/) ++ await answer(fourth.sessionId, fourth.message.source.exchangeId, 'Here it is.', 4) ++ await running2 ++ const text2 = (await reportAfter(1)).message.content[0].text ++ assert.match(text2, /Stayed silent: Writer\./) ++ assert.doesNotMatch(text2, /No answer to your @/, 'a called member that answered is not reported as missing') ++ ++ // Round three: the called member's turn ends with no reply at all (missed, not a passing string). ++ // It lands on the same sentence as a pass, once, and never twice. ++ await post('last one') ++ const controller = new AbortController() ++ const running3 = relay(room.id, 'r913-3', controller.signal) ++ const fifth = await turnFor(sent, first, second, third, fourth) ++ await compose(room.id, '@Editor ping') ++ await answer(fifth.sessionId, fifth.message.source.exchangeId, '[PASS]', 5) ++ const sixth = await turnFor(sent, first, second, third, fourth, fifth) ++ assert.match(sixth.message.content[0].text, /the user mentioned you/) ++ controller.abort() ++ await running3.catch(() => {}) ++ const text3 = (await reportAfter(2)).message.content[0].text ++ assert.match(text3, /No answer to your @: Editor\./, 'a turn that ended with no reply is reported the same way') ++ assert.equal((text3.match(/No answer to your @/g) ?? []).length, 1, 'reported once, not twice') ++ assert.match(text3, /Stayed silent: Writer\./) ++}) diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index ca07777fd..9dcf807ad 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -1044,7 +1044,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js const waiting = round.slots.findIndex((other, at) => at > index && other.id === peer.id && other.status === 'waiting') const called = waiting === -1 ? { id: peer.id, name: peer.name, status: 'waiting' } : round.slots.splice(waiting, 1)[0] called.note = note -@@ -285,34 +285,118 @@ export function install(rt) { +@@ -285,34 +285,120 @@ export function install(rt) { // Session's inbox, so it does not open another round when this one ends. Returns // the number of turns added, which the round's limit allows for. function drainRoomInbox(room, round, turns, index, agent) { @@ -1079,6 +1079,8 @@ diff --git a/src/host/groups.js b/src/host/groups.js + } + } + callMembers(round, turns, index - 1, speakers, note, ROOM_ADMIN_TURNS_EACH) ++ // The line names them, so the report says "no answer" rather than "stayed silent" (#913). ++ for (const slot of round.slots) if (slot.by === undefined && found.some(bot => bot.id === slot.id)) slot.addressed = true } - // The user spoke in this room, so Bots may start their own rounds again. - room.botRounds = 0 @@ -1234,7 +1236,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js } } -@@ -397,38 +397,44 @@ export function install(rt) { +@@ -397,38 +397,45 @@ export function install(rt) { rt.roomInbox.delete(room.id) const posts = queued.length > 0 ? queued : [{ text: '' }] const startSeq = room.seq + 1 @@ -1257,10 +1259,11 @@ diff --git a/src/host/groups.js b/src/host/groups.js - if (posts.some(post => !post.botId && !post.kickoff)) { - room.botRounds = 0 - room.lateRounds = 0 +- } + // A face-room line the send path already logged and sent is not logged or sent again (#287). + if (!post.botId && takePosted(room.id, post.text)) continue + logPost(room, post, text) - } ++ } + if (posts.some(post => !post.botId)) room.lateRounds = 0 await rt.save() - // An event post never picks the speakers over a message queued with it. @@ -1269,8 +1272,11 @@ diff --git a/src/host/groups.js b/src/host/groups.js + // 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 +- // 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 ++ // The members the opening line names: a Bot's post calls them to work for that Bot, a person's ++ // line addresses them. The report keeps that apart from a plain pass (#913). ++ const named = mentionsIn(opener.text ?? '', room.members.map(rt.botOf).filter(Boolean), opener.references ?? []).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 route = routeFor(room, opener, startSeq) @@ -1287,7 +1293,8 @@ diff --git a/src/host/groups.js b/src/host/groups.js + slots: speakers.map(member => ({ id: member.id, name: member.name, status: 'waiting', - ...(opener.kickoff ? { note: 'the group was just created' } : {}), - ...(named.includes(member) ? { by: opener.botId } : {}), +- ...(named.includes(member) ? { by: opener.botId } : {}), ++ ...(named.includes(member) ? (opener.botId === undefined ? { addressed: true } : { by: opener.botId }) : {}), })), + // 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 } : {}), @@ -1590,8 +1597,24 @@ diff --git a/src/host/groups.js b/src/host/groups.js async function reportRound(room, post, round) { const lines = round.replies.filter(reply => reply.kind === undefined).map(reply => `${reply.name}: ${reply.text}`) const waiting = round.replies.filter(reply => reply.kind === 'approval').map(reply => reply.name) -@@ -645,7 +645,11 @@ export function install(rt) { - round.passed.length > 0 ? `Stayed silent: ${round.passed.join(', ')}.` : '', +@@ -641,11 +641,26 @@ export function install(rt) { ++ // A turn that ended without a line: a pass, or no reply at all. A member the round's line named ++ // and that ended this way was addressed and did not answer — not the same thing as a member that ++ // read the line and had nothing to add (#913). `addressed` is the person's line, `by` the member ++ // that called it; the poster's own call is the "your @" one. ++ const silentTurn = slot => slot.status === 'passed' || slot.status === 'missed' ++ const mine = slot => slot.addressed === true || (slot.by !== undefined && slot.by === post.botId) ++ const addressed = round.slots.filter(slot => mine(slot) && silentTurn(slot)).map(slot => slot.name) ++ const called = round.slots.filter(slot => !mine(slot) && slot.by !== undefined && silentTurn(slot)).map(slot => slot.name) ++ const silent = round.passed.filter(name => !addressed.includes(name) && !called.includes(name)) + const text = [ + lines.length > 0 ? `Replies to your post (oldest first):\n${lines.join('\n')}` : 'Nobody replied to your post.', + working.length > 0 ? `Still working on it: ${working.join(', ')}. Each posts its result in the group when done, not to you; read_group_chat shows it.` : '', + waiting.length > 0 ? `Waiting for the user's approval: ${waiting.join(', ')}. What they say afterwards is posted in the group, not sent to you; read_group_chat shows it.` : '', +- round.passed.length > 0 ? `Stayed silent: ${round.passed.join(', ')}.` : '', ++ silent.length > 0 ? `Stayed silent: ${silent.join(', ')}.` : '', ++ addressed.length > 0 ? `No answer to your @: ${addressed.join(', ')}.` : '', ++ called.length > 0 ? `Called, but no answer: ${called.join(', ')}.` : '', round.failed.length > 0 ? `Could not answer because their model call failed: ${round.replies.filter(reply => reply.kind === 'failed').map(reply => `${reply.name} (${failureLine({ code: reply.code, status: reply.status, message: reply.text })})`).join(', ')}.` : '', ].filter(Boolean).join('\n\n') - await rt.deliver({ toId: post.botId, role: 'report', roomId: room.id, text, hop: (post.hop ?? 0) + 1 })