From 3040012fd6d23775406d538d968fad66b1ec0ab2 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sat, 24 Oct 2026 13:33:06 +0800 Subject: [PATCH 1/3] Call the member a Bot's own group line names, in the round that sees it A Bot that writes "@Writer ..." into a group did not always call Writer. Two shapes lost the call, both measured on main dfa25a2a: - Mid-round: post_to_group while a round is running is shown in that round by showInRound (labeled bubble, logged once) but only re-delivered to the group's Session after the report, so a line that arrived at 12:00 had its names called by a round opened later - and the report had already listed them as silent. showInRound now keeps the line's names (room members only, never the sender) as round.calls, and runRound drains them at its loop head (drainMidCalls) under the same ROOM_TURNS_EACH / ROOM_ADMIN_TURNS_EACH bound and the same round limit as a call from a reply, so there is no second uncapped path. The held post carries called: true (turns.js queues it, openingSpeakers opens no round for it), so the member is called once. - Late: past ROOM_LATE_ROUNDS a late line is quiet, and openingSpeakers returned [] on quiet before parsing its @ - a written name was swallowed and the admin answered instead. Mentions are resolved first now: quiet caps the spread of a line nobody was named in (it still returns nobody then) and never an explicit name. Event posts (failure/stop/resume) still call nobody. Tests in core/test/rounds.test.js: a mid-round line naming Writer calls it exactly once and its re-delivery calls nobody; a quiet late line naming Writer is answered by Writer, not the admin; negative controls - a name outside the room and the sender itself are no call, and a quiet line that names nobody calls nobody. patches/ds-bot regenerated for the three files these edits touch (groups.js, turns.js, rounds.test.js); docs/subsystems/core-host.md carries the #932 section. --- changelog.d/932-room-calls-mid-round.fixed.md | 1 + core/src/host/groups.js | 35 ++++- core/src/host/turns.js | 2 + core/test/rounds.test.js | 119 +++++++++++++++ docs/subsystems/core-host.md | 4 + patches/ds-bot/0001-core-fixes.patch | 47 +++--- patches/ds-bot/0005-social-seams.patch | 135 ++++++++++++++++-- patches/ds-bot/0012-room-membership.patch | 95 ++++++++---- 8 files changed, 375 insertions(+), 63 deletions(-) create mode 100644 changelog.d/932-room-calls-mid-round.fixed.md diff --git a/changelog.d/932-room-calls-mid-round.fixed.md b/changelog.d/932-room-calls-mid-round.fixed.md new file mode 100644 index 000000000..cf41a4c38 --- /dev/null +++ b/changelog.d/932-room-calls-mid-round.fixed.md @@ -0,0 +1 @@ +A Bot that names another member with `@Name` now gets that member called in the shape that used to lose the call: a line posted into the group while a round is running calls the members it names inside that same round (under the usual per-member and per-round caps, exactly once — the re-delivered line calls nobody again), and a late line after the round limit still calls the member it names instead of letting `quiet` swallow the name while the admin answers. A name outside the group, the sender's own name, and a member's failure or stop notice still call nobody ([#932](https://github.com/ThinkFlowLab/FlyBot/issues/932)). diff --git a/core/src/host/groups.js b/core/src/host/groups.js index 39cc2381a..c7856f41d 100644 --- a/core/src/host/groups.js +++ b/core/src/host/groups.js @@ -477,14 +477,32 @@ export function install(rt) { } const originOfPosts = posts => posts.reduce((verdict, post) => combine(verdict, postOrigin(post)), undefined) + // A member's own line that arrived while this round ran named other members (#932): the call happens here, + // inside this round and under the same cap, instead of waiting for the held post to open a round of its own. + // Returns the number of turns added, which the round's limit allows for. + function drainMidCalls(room, round, turns, index) { + const calls = round.calls?.splice(0) ?? [] + if (calls.length === 0) return 0 + const before = round.slots.length + for (const call of calls) { + const callees = call.callees.filter(bot => room.members.includes(bot.id)) + if (callees.length === 0) continue + const leads = rt.isAdminOf(room, call.by) + const uncapped = new Set(callees.filter(bot => rt.isAdminOf(room, bot.id)).map(bot => bot.id)) + callMembers(round, turns, index - 1, callees, `${call.name} mentioned you`, leads ? ROOM_ADMIN_TURNS_EACH : ROOM_TURNS_EACH, call.by, uncapped) + } + return round.slots.length - before + } + async function runRound(room, round, signal, agent) { let limit = round.slots.length + ROOM_EXTRA_TURNS + (room.admin ? ROOM_ADMIN_EXTRA_TURNS : 0) const turns = new Map() // The inbox check keeps a message sent during the last speaker's turn inside // this round: it drains at the loop's head even once the slots ran out. - for (let index = 0; (index < round.slots.length || waitingLines(agent).length > 0 || fallbackDue(room, round)) && round.turns < limit; index += 1) { + for (let index = 0; (index < round.slots.length || (round.calls?.length ?? 0) > 0 || waitingLines(agent).length > 0 || fallbackDue(room, round)) && round.turns < limit; index += 1) { if (signal.aborted) break limit += drainRoomInbox(room, round, turns, index, agent) + limit += drainMidCalls(room, round, turns, index) // Everyone passed on the owner's own plain line: the first Bot that passed answers, so the user is never ignored (#299). if (index >= round.slots.length && fallbackDue(room, round)) { const first = round.slots.find(slot => slot.status === 'passed' || slot.status === 'missed') @@ -834,9 +852,14 @@ export function install(rt) { // A friend's own attested plain line (or their @all): this home's Bots answer too (#295). The caller only // sets `plain` on lines the server attests as typed by that person; a Bot's line never does. if (opener.plain && isFaceRoom(room)) return plainSpeakers(members, admin) - if (opener.quiet) return [] + // An event post (a member's failure, stop or resume notice) opens no round, and a line a running round + // already called (#932) is not called again when it is re-delivered after that round. + if (isEvent(opener) || opener.called === true) return [] const { all, found: named } = mentionsIn(opener.text, everyone, opener.references) const found = named.filter(bot => bot.id !== opener.botId) + // A quiet line still calls the member it names (#932): `quiet` caps how far a line spreads when nobody + // is named (a late round past ROOM_LATE_ROUNDS), it never swallows a name that was written. + if (opener.quiet) return found if (opener.late) return found.length > 0 ? found : members.filter(bot => bot.id === opener.askedBy) // Face-to-face, the room's own person typing (#167, #287): @Name, the label the picker shows // ("Friend's Chief") or a handle picks that local Bot; a line to a friend's Bot starts none @@ -910,6 +933,14 @@ export function install(rt) { const posted = logPost(room, { botId: sender.id, name: sender.name }, text) void rt.save().catch(() => {}) round.replies.push({ botId: sender.id, name: sender.name, color: rt.botOf(sender.id)?.color, text, time: Date.now(), ...(posted?.seq === undefined ? {} : { seq: posted.seq }) }) + // The members this line names are called by the round that is running (#932): drawing the bubble alone + // left them uncalled until the held post opened a round after the report. `called` keeps the re-delivery + // of the held post from calling them a second time. + const callees = mentionsIn(text, room.members.map(rt.botOf).filter(Boolean)).found.filter(bot => bot.id !== sender.id) + if (callees.length > 0) { + round.calls = [...(round.calls ?? []), { by: sender.id, name: sender.name, callees }] + extra.called = true + } return true } function holdPost(room, agent, sender, text, extra) { diff --git a/core/src/host/turns.js b/core/src/host/turns.js index 2bdb69a32..b5eded5f8 100644 --- a/core/src/host/turns.js +++ b/core/src/host/turns.js @@ -433,6 +433,8 @@ export function install(rt) { ...(source.human ? { human: source.human } : {}), // Shown (and logged) by the round that was running when it arrived (groups.js, #455). ...(source.shown === true ? { shown: true } : {}), + // The round that was running already called the members this line names (#932). + ...(source.called === true ? { called: true } : {}), ...(source.plain ? { plain: true } : {}), ...(source.everyone ? { everyone: true } : {}), ...(Array.isArray(source.callIds) && source.callIds.length > 0 ? { callIds: source.callIds.filter(id => typeof id === 'string') } : {}), diff --git a/core/test/rounds.test.js b/core/test/rounds.test.js index 2f1791566..afb113db7 100644 --- a/core/test/rounds.test.js +++ b/core/test/rounds.test.js @@ -1670,3 +1670,122 @@ test('#913: a called member that never answers is reported apart from a member t assert.equal((text3.match(/No answer to your @/g) ?? []).length, 1, 'reported once, not twice') assert.match(text3, /Stayed silent: Writer\./) }) + +// #932: a Bot's own line that names a member must call it, in the round that is running or on the line +// itself. Two shapes lost the call: a `post_to_group` line that arrived mid-round (only the bubble was +// drawn, the call waited for a round after the report), and a late line past ROOM_LATE_ROUNDS whose +// `quiet` was read before its `@`. +const botLine = (senderId, name, text, extra = {}) => ({ type: 'user/message', data: { source: { kind: 'bot', role: 'post', senderSessionId: senderId, senderName: name, ...extra }, content: [{ type: 'text', text }] } }) + +test('#932: a mid-round Bot line that names a member calls it exactly once, and its re-delivery calls nobody', async (t) => { + const env = start() + const { call, emit, userMessage, relay, answer, stop, sent, rt, ctx } = env + t.after(stop) + const room = await team(call, sent) + await call('update-room', { id: room.id, mode: 'admin' }) + const state = (await call('state', {})).value + const chief = state.mainBotId + const writer = state.bots.find(bot => bot.name === 'Writer').id + const queued = [] + let busy = true + let markIdle + const idle = new Promise(resolve => { markIdle = resolve }) + t.after(() => { + busy = false + markIdle() + }) + const resolve = ctx.sessionController.resolveAgent + ctx.sessionController.resolveAgent = async sessionId => sessionId !== room.id ? resolve(sessionId) : ({ + agent: { session: { id: sessionId }, get status() { return busy ? 'running' : 'idle' }, whenIdle: () => idle, followup: message => queued.push(message) }, + }) + sent.length = 0 + await emit(room.id, userMessage('Plan the release.')) + const controller = new AbortController() + t.after(() => controller.abort()) + const running = relay(room.id, 'r932', controller.signal) + const turn = await turnFor(sent) + assert.equal(turn.sessionId, rt.roomOf(room.id).sessions?.[chief], 'the admin answers first') + + await rt.postToRoom(rt.roomOf(room.id), { id: chief, name: 'Chief' }, '@Writer draft the notes for 0.3.', { late: true, origin: 'other' }) + const live = [...rt.roomProgress.values()].find(entry => entry.roomId === room.id) + assert.equal(live.calls?.length, 1, 'the mention is queued as a call for the round that is running') + + await answer(turn.sessionId, turn.message.source.exchangeId, 'Notes come from Writer.', 1) + const called = await turnFor(sent, turn) + assert.equal(called?.sessionId, rt.roomOf(room.id).sessions?.[writer], 'Writer is called by the line that named it') + assert.match(promptOf(called), /Chief mentioned you/) + await answer(called.sessionId, called.message.source.exchangeId, 'Drafting the notes.', 2) + JSON.parse(await running) + assert.equal(roomTurns(sent).filter(entry => entry.sessionId === rt.roomOf(room.id).sessions?.[writer]).length, 1, 'exactly once, not twice') + + // The held line is delivered once the turn ends, marked as already called in its round: it calls nobody. + busy = false + markIdle() + const redelivered = await waitFor(() => queued[0]) + assert.equal(redelivered?.source.called, true, 'the re-delivery carries the call it already made') + sent.length = 0 + await emit(room.id, botLine(chief, 'Chief', '@Writer draft the notes for 0.3.', { late: true, called: true })) + JSON.parse(await relay(room.id, 'r932-again')) + assert.equal(roomTurns(sent).length, 0, 'the line that already called Writer does not call it again') +}) + +test('#932: a mid-round line calls only named members of the room, never a name outside it and never its sender', async (t) => { + const env = start() + const { call, emit, userMessage, relay, answer, stop, sent, rt, ctx } = env + t.after(stop) + const room = await team(call, sent) + await call('update-room', { id: room.id, mode: 'admin' }) + const state = (await call('state', {})).value + const chief = state.mainBotId + let busy = true + let markIdle + const idle = new Promise(resolve => { markIdle = resolve }) + t.after(() => { + busy = false + markIdle() + }) + const resolve = ctx.sessionController.resolveAgent + ctx.sessionController.resolveAgent = async sessionId => sessionId !== room.id ? resolve(sessionId) : ({ + agent: { session: { id: sessionId }, get status() { return busy ? 'running' : 'idle' }, whenIdle: () => idle, followup: () => {} }, + }) + sent.length = 0 + await emit(room.id, userMessage('Plan the release.')) + const controller = new AbortController() + t.after(() => controller.abort()) + const running = relay(room.id, 'r932-neg', controller.signal) + const turn = await turnFor(sent) + await rt.postToRoom(rt.roomOf(room.id), { id: chief, name: 'Chief' }, '@Nobody take this one, @Chief and me will check.', { late: true, origin: 'other' }) + const live = [...rt.roomProgress.values()].find(entry => entry.roomId === room.id) + assert.equal(live.calls, undefined, 'neither a name outside the room nor the sender itself is a call') + await answer(turn.sessionId, turn.message.source.exchangeId, 'Nobody then.', 1) + JSON.parse(await running) + assert.equal(roomTurns(sent).length, 1, 'only the admin spoke') +}) + +test('#932: a quiet late line still calls the member it names; an unnamed quiet line calls nobody', async (t) => { + const env = start() + const { call, emit, relay, answer, stop, sent, rt } = env + t.after(stop) + const room = await team(call, sent) + await call('update-room', { id: room.id, mode: 'admin' }) + const state = (await call('state', {})).value + const chief = state.mainBotId + const writer = state.bots.find(bot => bot.name === 'Writer').id + sent.length = 0 + // Past ROOM_LATE_ROUNDS a late line is quiet; the @Name is still a call. + await emit(room.id, botLine(chief, 'Chief', '@Writer check the notes.', { late: true, quiet: true })) + const controller = new AbortController() + t.after(() => controller.abort()) + const running = relay(room.id, 'q932', controller.signal) + const turn = await turnFor(sent) + assert.ok(turn, 'a quiet late line still calls the member it names') + assert.equal(turn.sessionId, rt.roomOf(room.id).sessions?.[writer], 'the named member answers, not the admin') + await answer(turn.sessionId, turn.message.source.exchangeId, 'Checked.', 1) + JSON.parse(await running) + assert.equal(roomTurns(sent).length, 1) + + sent.length = 0 + await emit(room.id, botLine(chief, 'Chief', 'Nothing else from me today.', { late: true, quiet: true })) + JSON.parse(await relay(room.id, 'q932-quiet', controller.signal)) + assert.equal(roomTurns(sent).length, 0, 'a quiet line that names nobody calls nobody') +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index c00140402..92ef64ea3 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -86,6 +86,10 @@ A member's group Session reads the group's fixed rules from a system-prompt sect 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`. +### An `@Name` in a Bot's own line calls that member (#932) + +A member's own line calls the members it names in two shapes where the call used to be lost. While a round runs, `post_to_group` used to be shown in the round and re-delivered to the Session after it, so the names in it were called only by whatever round that re-delivery opened — that is, after the round's report had already listed them as silent. `showInRound` now reads the line's `@` names (the members of the room, never the sender) and keeps them as `round.calls`; `runRound` drains them at its loop head (`drainMidCalls`) with the same `ROOM_TURNS_EACH` / `ROOM_ADMIN_TURNS_EACH` bound and the same round limit as a call made from a reply, so the call lands inside the running round and never bypasses the cap. The held post carries `called: true` (queued by `turns.js` into `roomInbox`) and `openingSpeakers` opens no round for it, so a named member is called exactly once. Second shape: a late line past `ROOM_LATE_ROUNDS` is quiet, and `openingSpeakers` read `quiet` before its `@` — an explicit name was swallowed and the admin answered a line that named someone else. The mentions are now resolved first: `quiet` caps only the spread of a line nobody was named in (it returns nobody then), while a name written in the line is called. An event post (a member's failure, stop or resume notice) still calls nobody, and a name outside the room or the sender itself is not a call. 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/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 5ff54f25c..50b27dd39 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -4113,7 +4113,7 @@ diff --git a/src/host/turns.js b/src/host/turns.js // A failed turn's `LlmFailure`, kept to what can be shown and logged. Like DSH's own // error row, an AUTH failure loses its message: providers may echo part of the key. -@@ -24,7 +24,7 @@ export const failureLine = failure => `${failure.code}${failure.status ? ` ${fai +@@ -24,7 +25,7 @@ // A group's own Session never reaches a model, so the snapshot DSH prepared there // rides with the user's queued message to the members who answer it. export function attachReference(queued, source, text) { @@ -4122,7 +4122,7 @@ diff --git a/src/host/turns.js b/src/host/turns.js if (post === undefined || text === '') return const labels = (Array.isArray(source?.references) ? source.references : []) .map(reference => reference?.label).filter(label => typeof label === 'string' && label !== '') -@@ -51,9 +51,10 @@ export function install(rt) { +@@ -51,9 +52,10 @@ // The retry goes where the turn's trigger came from: a group round's waiter or a // Bot's request keeps its exchange; anything else is a nudge in the Bot's own chat. @@ -4134,7 +4134,7 @@ diff --git a/src/host/turns.js b/src/host/turns.js ...(keeps && info.senderSessionId ? { fromId: info.senderSessionId } : {}), toId: rt.selfOf(id), sessionId: id, -@@ -70,13 +70,13 @@ export function install(rt) { +@@ -70,13 +72,13 @@ }) } @@ -4150,7 +4150,7 @@ diff --git a/src/host/turns.js b/src/host/turns.js const roomInbox = new Map() const queueRoomMessage = (roomId, item) => roomInbox.set(roomId, [...(roomInbox.get(roomId) ?? []), item]) // Member turns of a group round the user stopped: what they end with stays out of the group. -@@ -162,17 +162,30 @@ export function install(rt) { +@@ -162,17 +164,30 @@ // Roles whose turn owes the sender a reply: a 'stopped' notice asks the Bot to // acknowledge, exactly like a request. const expectsReply = info => info.role === 'request' || info.role === 'stopped' @@ -4182,7 +4182,7 @@ diff --git a/src/host/turns.js b/src/host/turns.js markExchangeTurn(sessionId, entry.turn) } } -@@ -196,7 +196,33 @@ export function install(rt) { +@@ -196,7 +211,33 @@ return merged } @@ -4217,7 +4217,7 @@ diff --git a/src/host/turns.js b/src/host/turns.js const workView = () => { const bots = {} for (const [id, entry] of live) { -@@ -204,7 +204,9 @@ export function install(rt) { +@@ -204,7 +245,9 @@ const owner = rt.groupOwners.get(id) const botId = owner?.botId ?? rt.chatOwners.get(id) if (botId === undefined || (owner !== undefined && bots[botId] !== undefined)) continue @@ -4228,7 +4228,7 @@ diff --git a/src/host/turns.js b/src/host/turns.js } return bots } -@@ -221,6 +221,7 @@ export function install(rt) { +@@ -221,6 +264,7 @@ botId: owner.botId, since: entry.since, ...(entry.tool ? { tool: entry.tool } : {}), @@ -4236,7 +4236,7 @@ diff --git a/src/host/turns.js b/src/host/turns.js ...(speaking.has(`${owner.roomId}:${owner.botId}`) ? { speaking: true } : {}), }] } -@@ -238,6 +238,45 @@ export function install(rt) { +@@ -238,6 +282,45 @@ return { ...(text ? { text: text.trim().slice(0, 300) } : {}), ...(code ? { code: code.trim().slice(0, 400) } : {}) } } @@ -4282,7 +4282,7 @@ diff --git a/src/host/turns.js b/src/host/turns.js // Where a Session speaks, for the diagnostics log. const placeOf = (id) => { const room = rt.roomOf(rt.groupOwners.get(id)?.roomId) ?? rt.roomOf(id) -@@ -266,6 +266,7 @@ export function install(rt) { +@@ -266,6 +349,7 @@ case 'tool/call': { const name = String(event.data?.name ?? '') startCall(id, entry, event.data?.callId, name, String(event.data?.arguments ?? '')) @@ -4290,7 +4290,7 @@ diff --git a/src/host/turns.js b/src/host/turns.js // A program is as quick as the tools it runs, so its sub-calls decide. if (rt.groupOwners.has(id) && name !== PROGRAM_TOOL) rt.workRoomTurn?.(id, name) break -@@ -288,11 +288,17 @@ export function install(rt) { +@@ -288,11 +372,17 @@ entry.since = event.time ?? Date.now() entry.texts = [] entry.info = undefined @@ -4308,7 +4308,7 @@ diff --git a/src/host/turns.js b/src/host/turns.js if (entry.pending !== undefined) { claimTrigger(id, entry, entry.pending) entry.pending = undefined -@@ -300,6 +300,14 @@ export function install(rt) { +@@ -300,6 +390,14 @@ break case 'user/message': { const source = event.data?.source @@ -4323,7 +4323,7 @@ diff --git a/src/host/turns.js b/src/host/turns.js // A message that shares a step with a bare handoff package is what the turn // answers, so the turn is not a hidden acknowledgement. const bare = info => info?.handoff === true && info.resume !== true -@@ -331,14 +331,21 @@ export function install(rt) { +@@ -331,14 +429,23 @@ hop: source.hop ?? 0, ...(source.late ? { late: true } : {}), ...(source.quiet ? { quiet: true } : {}), @@ -4331,6 +4331,8 @@ diff --git a/src/host/turns.js b/src/host/turns.js + ...(source.human ? { human: source.human } : {}), + // Shown (and logged) by the round that was running when it arrived (groups.js, #455). + ...(source.shown === true ? { shown: true } : {}), ++ // The round that was running already called the members this line names (#932). ++ ...(source.called === true ? { called: true } : {}), + ...(source.plain ? { plain: true } : {}), + ...(source.everyone ? { everyone: true } : {}), + ...(Array.isArray(source.callIds) && source.callIds.length > 0 ? { callIds: source.callIds.filter(id => typeof id === 'string') } : {}), @@ -4347,7 +4349,7 @@ diff --git a/src/host/turns.js b/src/host/turns.js } else if (source?.kind === SOURCE_KIND) { if (entry.turn === undefined) entry.pending = source else if (!opened) claimTrigger(id, entry, source) -@@ -363,12 +363,15 @@ export function install(rt) { +@@ -363,12 +470,15 @@ if (rt.roomOf(id) && source.kind === 'user') { const images = (Array.isArray(event.data.content) ? event.data.content : []) .filter(block => block?.type === 'image' && !block.offloaded).map(block => block.attachment) @@ -4368,7 +4370,7 @@ diff --git a/src/host/turns.js b/src/host/turns.js delete state.questions[id] void rt.save().catch(() => {}) } -@@ -386,11 +386,17 @@ export function install(rt) { +@@ -386,11 +496,17 @@ case 'turn/end': { setActivity(id, undefined) const info = entry.info @@ -4386,7 +4388,7 @@ diff --git a/src/host/turns.js b/src/host/turns.js entry.turn = undefined entry.info = undefined entry.texts = [] -@@ -399,11 +399,14 @@ export function install(rt) { +@@ -399,11 +515,14 @@ entry.since = undefined entry.tool = undefined entry.calls = undefined @@ -4401,7 +4403,7 @@ diff --git a/src/host/turns.js b/src/host/turns.js if (switched) break // A model that writes its tool call as text ends the turn as if it were done, // though nothing ran. It gets one more turn on the same trigger, so a waiting -@@ -411,7 +411,8 @@ export function install(rt) { +@@ -411,7 +530,8 @@ // the call. const wroteCall = failure === undefined && reason?.kind === 'completed' && lastCalled !== true && writesToolCall(lastText) const retry = wroteCall && info?.retried !== true && !(info?.role === 'room' && stoppedRoomTurns.has(info.exchangeId)) @@ -4411,7 +4413,7 @@ diff --git a/src/host/turns.js b/src/host/turns.js const replyText = () => { const reply = (wroteCall ? texts.map(stripWrittenCalls) : texts).join('\n\n').trim() return wroteCall && reply === '' ? 'Tried to run a tool but wrote the call as text, so it did not run.' : reply -@@ -419,6 +419,15 @@ export function install(rt) { +@@ -419,6 +539,15 @@ // Whoever the turn answers gets the same three-way rule: a failure notice, // the turn's text (or a stopped-before-answer line), or nothing. const answer = (source, reply) => { @@ -4427,7 +4429,7 @@ diff --git a/src/host/turns.js b/src/host/turns.js if (!expectsReply(source) || answersUser(source)) return const name = rt.botOf(rt.selfOf(id))?.name ?? 'The other Bot' // A sender that wrote from a group gets the reply in its Session there, while -@@ -434,6 +434,7 @@ export function install(rt) { +@@ -434,6 +563,7 @@ role: 'reply', hop: (source.hop ?? 0) + 1, replyTo: source.exchangeId, @@ -4435,7 +4437,7 @@ diff --git a/src/host/turns.js b/src/host/turns.js }).catch(error => rt.warn('ds-bot: failure notice delivery failed: %s', error)) } else if (reply !== '' || reason?.kind === 'aborted') { // A turn stopped before it could answer still owes the sender a line, -@@ -446,6 +446,7 @@ export function install(rt) { +@@ -446,6 +576,7 @@ role: 'reply', hop: (source.hop ?? 0) + 1, replyTo: source.exchangeId, @@ -4443,7 +4445,7 @@ diff --git a/src/host/turns.js b/src/host/turns.js }).catch(error => rt.warn('ds-bot: reply delivery failed: %s', error)) } } -@@ -460,14 +460,14 @@ export function install(rt) { +@@ -460,14 +591,14 @@ } else if (!stopped && reason?.kind !== 'aborted') { // The round moved on without this member; how its turn ends still reaches the group. if (failure !== undefined) void rt.lateRoomFailure?.(id, info, failure) @@ -4460,7 +4462,7 @@ diff --git a/src/host/turns.js b/src/host/turns.js } else { answer(info, replyText()) } -@@ -488,6 +488,7 @@ export function install(rt) { +@@ -488,6 +619,7 @@ hop: request.info.hop ?? 0, exchangeId: request.info.exchangeId, ...(request.info.senderRoomId ? { fromRoomId: request.info.senderRoomId } : {}), @@ -4468,7 +4470,7 @@ diff --git a/src/host/turns.js b/src/host/turns.js deferred: true, }).catch(error => rt.warn('ds-bot: deferred request delivery failed: %s', error)) } -@@ -496,5 +496,15 @@ export function install(rt) { +@@ -496,6 +628,16 @@ } }) @@ -4485,6 +4487,7 @@ diff --git a/src/host/turns.js b/src/host/turns.js + + Object.assign(rt, { botTurnOrigin, live, liveOf, handedFromGroup, roomWaiters, roomProgress, roomInbox, activityView, workView, roomWorkView, endedView, noteRunFailure, callDetail, stopRoomTurn }) } + diff --git a/src/host/usage.js b/src/host/usage.js --- a/src/host/usage.js +++ b/src/host/usage.js diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index e81b5fee5..1e68cc175 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -1087,7 +1087,7 @@ diff --git a/src/host/delivery.js b/src/host/delivery.js diff --git a/test/rounds.test.js b/test/rounds.test.js --- a/test/rounds.test.js +++ b/test/rounds.test.js -@@ -114,6 +114,8 @@ const waitFor = async (check) => { +@@ -114,6 +114,8 @@ return undefined } const turnFor = (sent, ...earlier) => waitFor(() => sent.find(entry => !earlier.includes(entry) && entry.message.source.role === 'room')) @@ -1096,7 +1096,7 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js test('a new group Session waits for capabilities before its first member prompt', async (t) => { const { call, emit, userMessage, relay, answer, stop, sent, rt } = start() -@@ -180,6 +180,18 @@ test('a message the composer queues mid-round joins the round and leaves the Ses +@@ -180,6 +182,18 @@ assert.deepEqual(inboxOf(room.id).nextTurn, []) }) @@ -1115,7 +1115,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) -@@ -253,32 +253,6 @@ test('in admin mode a plain mid-round message goes to the admin', async (t) => { +@@ -253,32 +267,6 @@ assert.deepEqual(result.replies.map(reply => reply.name ?? 'User'), ['Chief', 'Writer', 'User', 'Chief']) }) @@ -1148,7 +1148,7 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js test('a message with an image or a chat reference waits for its own round, and so does what follows it', async (t) => { const { call, emit, userMessage, compose, relay, inboxOf, answer, stop, sent } = start() t.after(stop) -@@ -300,19 +300,26 @@ test('a message with an image or a chat reference waits for its own round, and s +@@ -300,19 +288,26 @@ assert.equal(inboxOf(room.id).nextStep.length, 1) }) @@ -1182,7 +1182,7 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js }) 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) => +@@ -331,6 +326,377 @@ assert.equal(inboxOf(room.id).nextTurn.length, 1) }) @@ -1560,11 +1560,10 @@ 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,960 @@ - const result = JSON.parse(await running) +@@ -348,3 +714,1079 @@ assert.deepEqual(result.replies.map(reply => reply.name ?? 'User'), ['Chief', 'Writer']) }) -+ + +// A face-to-face room with my Chief and Writer and a friend's Chief; `plain` helpers for #299. +async function routedRoom(ctxs, id) { + const { call, rt, sent } = ctxs @@ -2521,3 +2520,123 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js + assert.equal((text3.match(/No answer to your @/g) ?? []).length, 1, 'reported once, not twice') + assert.match(text3, /Stayed silent: Writer\./) +}) ++ ++// #932: a Bot's own line that names a member must call it, in the round that is running or on the line ++// itself. Two shapes lost the call: a `post_to_group` line that arrived mid-round (only the bubble was ++// drawn, the call waited for a round after the report), and a late line past ROOM_LATE_ROUNDS whose ++// `quiet` was read before its `@`. ++const botLine = (senderId, name, text, extra = {}) => ({ type: 'user/message', data: { source: { kind: 'bot', role: 'post', senderSessionId: senderId, senderName: name, ...extra }, content: [{ type: 'text', text }] } }) ++ ++test('#932: a mid-round Bot line that names a member calls it exactly once, and its re-delivery calls nobody', async (t) => { ++ const env = start() ++ const { call, emit, userMessage, relay, answer, stop, sent, rt, ctx } = env ++ t.after(stop) ++ const room = await team(call, sent) ++ await call('update-room', { id: room.id, mode: 'admin' }) ++ const state = (await call('state', {})).value ++ const chief = state.mainBotId ++ const writer = state.bots.find(bot => bot.name === 'Writer').id ++ const queued = [] ++ let busy = true ++ let markIdle ++ const idle = new Promise(resolve => { markIdle = resolve }) ++ t.after(() => { ++ busy = false ++ markIdle() ++ }) ++ const resolve = ctx.sessionController.resolveAgent ++ ctx.sessionController.resolveAgent = async sessionId => sessionId !== room.id ? resolve(sessionId) : ({ ++ agent: { session: { id: sessionId }, get status() { return busy ? 'running' : 'idle' }, whenIdle: () => idle, followup: message => queued.push(message) }, ++ }) ++ sent.length = 0 ++ await emit(room.id, userMessage('Plan the release.')) ++ const controller = new AbortController() ++ t.after(() => controller.abort()) ++ const running = relay(room.id, 'r932', controller.signal) ++ const turn = await turnFor(sent) ++ assert.equal(turn.sessionId, rt.roomOf(room.id).sessions?.[chief], 'the admin answers first') ++ ++ await rt.postToRoom(rt.roomOf(room.id), { id: chief, name: 'Chief' }, '@Writer draft the notes for 0.3.', { late: true, origin: 'other' }) ++ const live = [...rt.roomProgress.values()].find(entry => entry.roomId === room.id) ++ assert.equal(live.calls?.length, 1, 'the mention is queued as a call for the round that is running') ++ ++ await answer(turn.sessionId, turn.message.source.exchangeId, 'Notes come from Writer.', 1) ++ const called = await turnFor(sent, turn) ++ assert.equal(called?.sessionId, rt.roomOf(room.id).sessions?.[writer], 'Writer is called by the line that named it') ++ assert.match(promptOf(called), /Chief mentioned you/) ++ await answer(called.sessionId, called.message.source.exchangeId, 'Drafting the notes.', 2) ++ JSON.parse(await running) ++ assert.equal(roomTurns(sent).filter(entry => entry.sessionId === rt.roomOf(room.id).sessions?.[writer]).length, 1, 'exactly once, not twice') ++ ++ // The held line is delivered once the turn ends, marked as already called in its round: it calls nobody. ++ busy = false ++ markIdle() ++ const redelivered = await waitFor(() => queued[0]) ++ assert.equal(redelivered?.source.called, true, 'the re-delivery carries the call it already made') ++ sent.length = 0 ++ await emit(room.id, botLine(chief, 'Chief', '@Writer draft the notes for 0.3.', { late: true, called: true })) ++ JSON.parse(await relay(room.id, 'r932-again')) ++ assert.equal(roomTurns(sent).length, 0, 'the line that already called Writer does not call it again') ++}) ++ ++test('#932: a mid-round line calls only named members of the room, never a name outside it and never its sender', async (t) => { ++ const env = start() ++ const { call, emit, userMessage, relay, answer, stop, sent, rt, ctx } = env ++ t.after(stop) ++ const room = await team(call, sent) ++ await call('update-room', { id: room.id, mode: 'admin' }) ++ const state = (await call('state', {})).value ++ const chief = state.mainBotId ++ let busy = true ++ let markIdle ++ const idle = new Promise(resolve => { markIdle = resolve }) ++ t.after(() => { ++ busy = false ++ markIdle() ++ }) ++ const resolve = ctx.sessionController.resolveAgent ++ ctx.sessionController.resolveAgent = async sessionId => sessionId !== room.id ? resolve(sessionId) : ({ ++ agent: { session: { id: sessionId }, get status() { return busy ? 'running' : 'idle' }, whenIdle: () => idle, followup: () => {} }, ++ }) ++ sent.length = 0 ++ await emit(room.id, userMessage('Plan the release.')) ++ const controller = new AbortController() ++ t.after(() => controller.abort()) ++ const running = relay(room.id, 'r932-neg', controller.signal) ++ const turn = await turnFor(sent) ++ await rt.postToRoom(rt.roomOf(room.id), { id: chief, name: 'Chief' }, '@Nobody take this one, @Chief and me will check.', { late: true, origin: 'other' }) ++ const live = [...rt.roomProgress.values()].find(entry => entry.roomId === room.id) ++ assert.equal(live.calls, undefined, 'neither a name outside the room nor the sender itself is a call') ++ await answer(turn.sessionId, turn.message.source.exchangeId, 'Nobody then.', 1) ++ JSON.parse(await running) ++ assert.equal(roomTurns(sent).length, 1, 'only the admin spoke') ++}) ++ ++test('#932: a quiet late line still calls the member it names; an unnamed quiet line calls nobody', async (t) => { ++ const env = start() ++ const { call, emit, relay, answer, stop, sent, rt } = env ++ t.after(stop) ++ const room = await team(call, sent) ++ await call('update-room', { id: room.id, mode: 'admin' }) ++ const state = (await call('state', {})).value ++ const chief = state.mainBotId ++ const writer = state.bots.find(bot => bot.name === 'Writer').id ++ sent.length = 0 ++ // Past ROOM_LATE_ROUNDS a late line is quiet; the @Name is still a call. ++ await emit(room.id, botLine(chief, 'Chief', '@Writer check the notes.', { late: true, quiet: true })) ++ const controller = new AbortController() ++ t.after(() => controller.abort()) ++ const running = relay(room.id, 'q932', controller.signal) ++ const turn = await turnFor(sent) ++ assert.ok(turn, 'a quiet late line still calls the member it names') ++ assert.equal(turn.sessionId, rt.roomOf(room.id).sessions?.[writer], 'the named member answers, not the admin') ++ await answer(turn.sessionId, turn.message.source.exchangeId, 'Checked.', 1) ++ JSON.parse(await running) ++ assert.equal(roomTurns(sent).length, 1) ++ ++ sent.length = 0 ++ await emit(room.id, botLine(chief, 'Chief', 'Nothing else from me today.', { late: true, quiet: true })) ++ JSON.parse(await relay(room.id, 'q932-quiet', controller.signal)) ++ assert.equal(roomTurns(sent).length, 0, 'a quiet line that names nobody calls nobody') ++}) ++ diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index 9dcf807ad..3b8844867 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -665,7 +665,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js const ROOM_LOG_MAX = 60 const ROOM_FIRST_LOOK = 12 -@@ -31,6 +31,10 @@ const ROOM_EXTRA_TURNS = 4 +@@ -31,6 +39,10 @@ const ROOM_ADMIN_EXTRA_TURNS = 4 const ROOM_TURNS_EACH = 2 const ROOM_ADMIN_TURNS_EACH = 4 @@ -676,7 +676,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js // Logs written before rooms were versioned get sequence numbers in place. const ensureRoomLog = (room) => { -@@ -41,9 +41,64 @@ const ensureRoomLog = (room) => { +@@ -41,11 +53,66 @@ room.seen ??= {} } @@ -691,8 +691,8 @@ diff --git a/src/host/groups.js b/src/host/groups.js + room.log = [...room.log, stamped].slice(-ROOM_LOG_MAX) + void fanoutRoomLine(room, stamped) + return stamped -+} -+ + } + +// One answer per Bot to a person's plain line in a face-to-face room (#295, #456). The room keeps the +// line's seq, the Bots that answered it and the turns it asked for; a Bot asked again for the same line +// (the line delivered twice, or a turn that went on working and posts late after a second one) passes. @@ -739,10 +739,12 @@ diff --git a/src/host/groups.js b/src/host/groups.js + if (at === -1) return false + postedLines.set(roomId, list.toSpliced(at, 1)) + return true - } - ++} ++ // A member's own lines are already in its Session, so only the others' are news. -@@ -58,6 +58,9 @@ const lineText = (line) => { + const unseenLines = (room, memberId) => { + const since = room.seen[memberId] +@@ -58,6 +125,9 @@ if (line.kind === 'resumed') return `[${line.who}'s work here was cut off when DeepSeek Harness restarted; it is picking it back up and posts when done]` if (line.kind === 'cut') return `[${line.who}'s work here was cut off by a restart again, so it stopped; ask it to go on if it still matters]` if (line.kind === 'stopped') return `[The user stopped ${line.who}'s work here]` @@ -752,7 +754,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js return `${line.who}: ${line.text}` } const transcript = lines => lines.map(lineText).join('\n') -@@ -86,6 +86,11 @@ export const referenceNote = references => (references?.length +@@ -86,6 +156,11 @@ : '') const ROOM_REPLY = 'Reply with your message for the group, no name prefix: in a discussion, one or two short messages. When you are asked to do something, do it now with your tools. The group does not wait for you, and your final reply is posted here when you finish, so give the result, never a promise to do it later. Reply [PASS] to stay silent when you have nothing new to add or the message is for someone else.' @@ -764,7 +766,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js const ROOM_CALLS = 'Writing "@Name" calls that member next, to answer or to do what you ask; when it finishes work you gave it, you answer its result. To only mention a member, write its name without "@".' const slotLabel = (slot) => { -@@ -100,52 +100,138 @@ const slotLabel = (slot) => { +@@ -100,52 +175,138 @@ return slot.name } @@ -930,7 +932,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js const lines = unseenLines(room, member.id) const earlier = lines.filter(line => line.seq < round.startSeq).slice(-ROOM_EARLIER_MAX) const current = lines.filter(line => line.seq >= round.startSeq) -@@ -153,14 +153,9 @@ export function install(rt) { +@@ -153,14 +314,9 @@ const index = round.slots.findIndex(slot => slot.status === 'speaking') const before = index const after = round.slots.length - index - 1 @@ -946,7 +948,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js earlier.length ? `Earlier in the group, before this round (context only):\n${transcript(earlier)}` : '', current.length ? `This round so far${spokeThisRound ? ', since your last message' : ', starting with the new message'} (oldest first):\n${transcript(current)}` -@@ -169,90 +169,27 @@ export function install(rt) { +@@ -169,90 +325,27 @@ working.length > 0 ? `Still at work on what they were asked; each posts here when done: ${working.map(item => `${item.bot.name} (${minutesSince(item.since)} min)`).join(', ')}.` : '', `Speaking order for this round: ${round.slots.map(slotLabel).join(' → ')}.`, `You are number ${index + 1} of ${round.slots.length}. ${before === 0 ? 'Nobody spoke before you' : `${before} went before you`}${after === 0 ? ', and you are the last.' : `; ${after} after you will see your message.`}`, @@ -1044,7 +1046,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,120 @@ export function install(rt) { +@@ -285,34 +378,138 @@ // 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) { @@ -1153,6 +1155,23 @@ diff --git a/src/host/groups.js b/src/host/groups.js + return post.origin === OWNER || post.origin === SCHEDULED ? OWNER : OTHER + } + const originOfPosts = posts => posts.reduce((verdict, post) => combine(verdict, postOrigin(post)), undefined) ++ ++ // A member's own line that arrived while this round ran named other members (#932): the call happens here, ++ // inside this round and under the same cap, instead of waiting for the held post to open a round of its own. ++ // Returns the number of turns added, which the round's limit allows for. ++ function drainMidCalls(room, round, turns, index) { ++ const calls = round.calls?.splice(0) ?? [] ++ if (calls.length === 0) return 0 ++ const before = round.slots.length ++ for (const call of calls) { ++ const callees = call.callees.filter(bot => room.members.includes(bot.id)) ++ if (callees.length === 0) continue ++ const leads = rt.isAdminOf(room, call.by) ++ const uncapped = new Set(callees.filter(bot => rt.isAdminOf(room, bot.id)).map(bot => bot.id)) ++ callMembers(round, turns, index - 1, callees, `${call.name} mentioned you`, leads ? ROOM_ADMIN_TURNS_EACH : ROOM_TURNS_EACH, call.by, uncapped) ++ } ++ return round.slots.length - before ++ } + async function runRound(room, round, signal, agent) { let limit = round.slots.length + ROOM_EXTRA_TURNS + (room.admin ? ROOM_ADMIN_EXTRA_TURNS : 0) @@ -1160,9 +1179,10 @@ diff --git a/src/host/groups.js b/src/host/groups.js // The inbox check keeps a message sent during the last speaker's turn inside // this round: it drains at the loop's head even once the slots ran out. - for (let index = 0; (index < round.slots.length || waitingLines(agent).length > 0) && round.turns < limit; index += 1) { -+ for (let index = 0; (index < round.slots.length || waitingLines(agent).length > 0 || fallbackDue(room, round)) && round.turns < limit; index += 1) { ++ for (let index = 0; (index < round.slots.length || (round.calls?.length ?? 0) > 0 || waitingLines(agent).length > 0 || fallbackDue(room, round)) && round.turns < limit; index += 1) { if (signal.aborted) break limit += drainRoomInbox(room, round, turns, index, agent) ++ limit += drainMidCalls(room, round, turns, index) + // Everyone passed on the owner's own plain line: the first Bot that passed answers, so the user is never ignored (#299). + if (index >= round.slots.length && fallbackDue(room, round)) { + const first = round.slots.find(slot => slot.status === 'passed' || slot.status === 'missed') @@ -1173,7 +1193,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js if (index >= round.slots.length) break const slot = round.slots[index] const member = rt.botOf(slot.id) -@@ -321,6 +321,13 @@ export function install(rt) { +@@ -321,6 +518,13 @@ slot.status = 'left' continue } @@ -1187,7 +1207,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js slot.status = 'speaking' round.speaking = member.id round.turns += 1 -@@ -339,7 +339,13 @@ export function install(rt) { +@@ -339,7 +543,13 @@ const prompt = roomPrompt(room, member, round) round.shown?.push(member.id) const text = session.brief === undefined ? prompt : `${session.brief}\n\n${prompt}` @@ -1202,7 +1222,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js round.speaking = null if (reply?.failure !== undefined) { // Not a pass: the user sees why the member is silent, and the round goes on. -@@ -366,9 +366,18 @@ export function install(rt) { +@@ -366,9 +576,18 @@ if (reply?.missed) round.replies.push({ kind: 'missed', botId: member.id, name: member.name, color: member.color, time: Date.now() }) continue } @@ -1223,7 +1243,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js void rt.save().catch(() => {}) // Only the admin's "@all" calls everyone, so a member cannot set off a storm. const leads = rt.isAdminOf(room, member.id) -@@ -377,7 +377,11 @@ export function install(rt) { +@@ -377,7 +596,11 @@ const { all, found: named } = mentionsIn(reply, everyone) const found = named.filter(other => other.id !== member.id) const callees = leads && all ? [...found, ...others.filter(other => !found.includes(other))] : found @@ -1236,7 +1256,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js } } -@@ -397,38 +397,45 @@ export function install(rt) { +@@ -397,38 +620,45 @@ rt.roomInbox.delete(room.id) const posts = queued.length > 0 ? queued : [{ text: '' }] const startSeq = room.seq + 1 @@ -1259,11 +1279,10 @@ 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. @@ -1303,7 +1322,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js speaking: null, turns: 0, replies: [], -@@ -436,6 +436,7 @@ export function install(rt) { +@@ -436,6 +666,7 @@ failed: [], started: [], } @@ -1311,7 +1330,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js for (const post of posts.filter(item => item.failed)) { const bot = rt.botOf(post.botId) const { code, status, message } = post.failed -@@ -450,17 +450,25 @@ export function install(rt) { +@@ -450,17 +681,25 @@ appendRoom(room, { who: name, botId: post.botId, kind, text: '' }) round.replies.push({ kind, botId: post.botId, name, color: bot?.color, time: Date.now() }) } @@ -1338,7 +1357,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js } await rt.save() for (const post of posts) { -@@ -471,6 +471,37 @@ export function install(rt) { +@@ -471,6 +710,37 @@ }, })) @@ -1376,7 +1395,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js // Private context enters a group only through this tool, and the group sees each use. ctx.tools.register(defineTool({ name: 'read_own_chat', -@@ -539,7 +539,9 @@ export function install(rt) { +@@ -539,7 +809,9 @@ const { room, error } = rt.pickRoom(args.group, self) if (error) return error ensureRoomLog(room) @@ -1387,7 +1406,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,138 @@ export function install(rt) { +@@ -548,25 +820,151 @@ // 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. @@ -1418,15 +1437,21 @@ diff --git a/src/host/groups.js b/src/host/groups.js const members = everyone.filter(bot => bot.id !== opener.botId) const admin = members.find(bot => rt.isAdminOf(room, bot.id)) - if (opener.kickoff) return admin ? [admin] : [] +- if (opener.quiet) return [] + // A friend's own typed line that @-calls Bots here (#287): only those Bots answer. The caller + // resolved the names and only passes lines the server attests as typed by that person. + if (opener.callIds?.length > 0) return members.filter(bot => opener.callIds.includes(bot.id)) + // A friend's own attested plain line (or their @all): this home's Bots answer too (#295). The caller only + // sets `plain` on lines the server attests as typed by that person; a Bot's line never does. + if (opener.plain && isFaceRoom(room)) return plainSpeakers(members, admin) - if (opener.quiet) return [] ++ // An event post (a member's failure, stop or resume notice) opens no round, and a line a running round ++ // already called (#932) is not called again when it is re-delivered after that round. ++ if (isEvent(opener) || opener.called === true) return [] const { all, found: named } = mentionsIn(opener.text, everyone, opener.references) const found = named.filter(bot => bot.id !== opener.botId) ++ // A quiet line still calls the member it names (#932): `quiet` caps how far a line spreads when nobody ++ // is named (a late round past ROOM_LATE_ROUNDS), it never swallows a name that was written. ++ if (opener.quiet) return found if (opener.late) return found.length > 0 ? found : members.filter(bot => bot.id === opener.askedBy) + // Face-to-face, the room's own person typing (#167, #287): @Name, the label the picker shows + // ("Friend's Chief") or a handle picks that local Bot; a line to a friend's Bot starts none @@ -1500,6 +1525,14 @@ diff --git a/src/host/groups.js b/src/host/groups.js + const posted = logPost(room, { botId: sender.id, name: sender.name }, text) + void rt.save().catch(() => {}) + round.replies.push({ botId: sender.id, name: sender.name, color: rt.botOf(sender.id)?.color, text, time: Date.now(), ...(posted?.seq === undefined ? {} : { seq: posted.seq }) }) ++ // The members this line names are called by the round that is running (#932): drawing the bubble alone ++ // left them uncalled until the held post opened a round after the report. `called` keeps the re-delivery ++ // of the held post from calling them a second time. ++ const callees = mentionsIn(text, room.members.map(rt.botOf).filter(Boolean)).found.filter(bot => bot.id !== sender.id) ++ if (callees.length > 0) { ++ round.calls = [...(round.calls ?? []), { by: sender.id, name: sender.name, callees }] ++ extra.called = true ++ } + return true + } + function holdPost(room, agent, sender, text, extra) { @@ -1528,7 +1561,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js source: { kind: SOURCE_KIND, form: 'relay', role: 'post', exchangeId: randomUUID(), senderName: sender.name, senderSessionId: sender.id, roomId: room.id, hop: 0, ...extra }, })) await ctx.sessions.flush(agent.session) -@@ -577,17 +577,24 @@ export function install(rt) { +@@ -577,17 +975,24 @@ // approval) posts what its turn ended with. The member that asked for it and anyone // it names answer; past the late round limit nobody does, so late posts cannot keep a // group talking. @@ -1555,7 +1588,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js .catch(error => rt.warn('ds-bot: late group reply from %s failed: %s', member.name, error)) } rt.lateRoomReply = lateRoomReply -@@ -599,9 +599,8 @@ export function install(rt) { +@@ -599,9 +1004,8 @@ const room = rt.roomOf(roomId) const member = rt.botOf(botId) const sessionId = room?.sessions?.[botId] @@ -1567,7 +1600,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js await postToRoom(room, member, '', { quiet: true, stopped: true }) return true } -@@ -615,25 +615,15 @@ export function install(rt) { +@@ -615,37 +1019,42 @@ const member = rt.botOf(owner?.botId) if (room === undefined || member === undefined || !room.members.includes(member.id)) return const { code, status, message } = failure @@ -1597,7 +1630,7 @@ 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) -@@ -641,11 +641,26 @@ export function install(rt) { + 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 @@ -1626,7 +1659,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js .catch(error => rt.warn('ds-bot: group report to %s failed: %s', post.name, error)) } -@@ -665,7 +665,7 @@ export function install(rt) { +@@ -665,7 +1074,7 @@ // message from the user, a scheduled task, or a Bot's post after the last model or // tool step starts a relay. const opens = message => message.role === 'user' From 149d3397f3b2c233b499c79843c7e48098c964e6 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sat, 24 Oct 2026 13:45:04 +0800 Subject: [PATCH 2/3] Fix the regenerated patches: the phantom trailing line broke one hunk per file Each regenerated section ended its last hunk with one context line too many: a file that ends with a newline splits into a phantom trailing empty line, and it was emitted as a real context line, so git apply refused turns.js:496 (and the same hunk shape in groups.js and rounds.test.js). Dropped on both sides before diffing. All 1025 hunks of the series now carry matching counts and context at their declared old positions, and rebuilding core/ from ds-bot/ + files/ + the series gives back the committed core/ (348 files, 0 mismatched). --- patches/ds-bot/0001-core-fixes.patch | 3 +-- patches/ds-bot/0005-social-seams.patch | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 50b27dd39..b429027db 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -4470,7 +4470,7 @@ diff --git a/src/host/turns.js b/src/host/turns.js deferred: true, }).catch(error => rt.warn('ds-bot: deferred request delivery failed: %s', error)) } -@@ -496,6 +628,16 @@ +@@ -496,5 +628,15 @@ } }) @@ -4487,7 +4487,6 @@ diff --git a/src/host/turns.js b/src/host/turns.js + + Object.assign(rt, { botTurnOrigin, live, liveOf, handedFromGroup, roomWaiters, roomProgress, roomInbox, activityView, workView, roomWorkView, endedView, noteRunFailure, callDetail, stopRoomTurn }) } - diff --git a/src/host/usage.js b/src/host/usage.js --- a/src/host/usage.js +++ b/src/host/usage.js diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 1e68cc175..60564edae 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -1560,10 +1560,11 @@ 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) -@@ -348,3 +714,1079 @@ +@@ -347,3 +713,1079 @@ + const result = JSON.parse(await running) assert.deepEqual(result.replies.map(reply => reply.name ?? 'User'), ['Chief', 'Writer']) }) - ++ +// A face-to-face room with my Chief and Writer and a friend's Chief; `plain` helpers for #299. +async function routedRoom(ctxs, id) { + const { call, rt, sent } = ctxs @@ -2639,4 +2640,3 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js + JSON.parse(await relay(room.id, 'q932-quiet', controller.signal)) + assert.equal(roomTurns(sent).length, 0, 'a quiet line that names nobody calls nobody') +}) -+ From 9bba2876c32d1c0194f96549cc5ebf4691342c5b Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sat, 24 Oct 2026 13:51:46 +0800 Subject: [PATCH 3/3] Fix the regenerated patches: the phantom trailing line broke one hunk per file Each regenerated section ended its last hunk with one context line too many: a file that ends with a newline splits into a phantom trailing empty line, and it was emitted as a real context line, so git apply refused turns.js:496 (and the same hunk shape in groups.js and rounds.test.js). Dropped on both sides before diffing. All 1025 hunks of the series now carry matching counts and context at their declared old positions, and rebuilding core/ from ds-bot/ + files/ + the series gives back the committed core/ (348 files, 0 mismatched). --- core/test/rounds.test.js | 52 ++++++++++++++++++++++++- patches/ds-bot/0005-social-seams.patch | 54 ++++++++++++++++++++++++-- 2 files changed, 101 insertions(+), 5 deletions(-) diff --git a/core/test/rounds.test.js b/core/test/rounds.test.js index afb113db7..8665dbb7c 100644 --- a/core/test/rounds.test.js +++ b/core/test/rounds.test.js @@ -1613,8 +1613,8 @@ test('#913: a called member that never answers is reported apart from a member t 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] }) + const editor = (await call('create-bot', { name: 'Editor' })).value.id + await call('update-room', { id: room.id, add: [editor] }) 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. @@ -1789,3 +1789,51 @@ test('#932: a quiet late line still calls the member it names; an unnamed quiet JSON.parse(await relay(room.id, 'q932-quiet', controller.signal)) assert.equal(roomTurns(sent).length, 0, 'a quiet line that names nobody calls nobody') }) + +// #932, second observation point: a member called by a Bot's mid-round line must not be written into +// `Stayed silent:` in the closing report - that sentence is for a member the line never called (#913). +test('#932: a member called mid-round is reported as called, never as stayed silent', async (t) => { + const env = start() + const { call, emit, relay, answer, stop, sent, rt, ctx } = env + t.after(stop) + const room = await team(call, sent) + await call('update-room', { id: room.id, mode: 'admin' }) + const editor = (await call('create-bot', { name: 'Editor' })).value + await call('update-room', { id: room.id, add: [editor.id] }) + const state = (await call('state', {})).value + const chief = state.mainBotId + const writer = state.bots.find(bot => bot.name === 'Writer').id + let busy = true + let markIdle + const idle = new Promise(resolve => { markIdle = resolve }) + t.after(() => { + busy = false + markIdle() + }) + const resolve = ctx.sessionController.resolveAgent + ctx.sessionController.resolveAgent = async sessionId => sessionId !== room.id ? resolve(sessionId) : ({ + agent: { session: { id: sessionId }, get status() { return busy ? 'running' : 'idle' }, whenIdle: () => idle, followup: () => {} }, + }) + sent.length = 0 + // A Bot posts with report_back, so the closing report comes back to it. Its line names only Writer, so the + // Editor is not in the round's order: it can only be called by the line Writer posts while it works. + await emit(room.id, { type: 'user/message', data: { source: { kind: 'bot', role: 'post', senderSessionId: chief, senderName: 'Chief', reportBack: true }, content: [{ type: 'text', text: '@Writer check the draft' }] } }) + const controller = new AbortController() + t.after(() => controller.abort()) + const running = relay(room.id, 'r932-report', controller.signal) + const first = await turnFor(sent) + assert.equal(first.sessionId, rt.roomOf(room.id).sessions?.[writer], 'the named Writer answers') + // Writer names the Editor while its own turn runs: the call lands in this round. + await rt.postToRoom(rt.roomOf(room.id), { id: writer, name: 'Writer' }, '@Editor check the notes.', { late: true, origin: 'other' }) + await answer(first.sessionId, first.message.source.exchangeId, 'Draft is in the folder.', 1) + const second = await turnFor(sent, first) + assert.ok(second, 'the mid-round call reaches the Editor inside this round') + assert.notEqual(second.sessionId, first.sessionId, 'the Editor, not Writer again') + assert.match(promptOf(second), /Writer mentioned you/) + await answer(second.sessionId, second.message.source.exchangeId, '[PASS]', 2) + JSON.parse(await running) + const report = await waitFor(() => sent.filter(entry => entry.message?.source?.role === 'report')[0]) + const text = report?.message.content[0].text ?? '' + assert.match(text, /Called, but no answer: Editor\./, 'a member called mid-round that said nothing is reported as called') + assert.doesNotMatch(text, /Stayed silent: [^.]*Editor/, 'never in the sentence for a member nobody called') +}) diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 60564edae..f644731c9 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 +713,1079 @@ +@@ -347,3 +713,1127 @@ const result = JSON.parse(await running) assert.deepEqual(result.replies.map(reply => reply.name ?? 'User'), ['Chief', 'Writer']) }) @@ -2464,8 +2464,8 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js + 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] }) ++ const editor = (await call('create-bot', { name: 'Editor' })).value.id ++ await call('update-room', { id: room.id, add: [editor] }) + 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. @@ -2640,3 +2640,51 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js + JSON.parse(await relay(room.id, 'q932-quiet', controller.signal)) + assert.equal(roomTurns(sent).length, 0, 'a quiet line that names nobody calls nobody') +}) ++ ++// #932, second observation point: a member called by a Bot's mid-round line must not be written into ++// `Stayed silent:` in the closing report - that sentence is for a member the line never called (#913). ++test('#932: a member called mid-round is reported as called, never as stayed silent', async (t) => { ++ const env = start() ++ const { call, emit, relay, answer, stop, sent, rt, ctx } = env ++ t.after(stop) ++ const room = await team(call, sent) ++ await call('update-room', { id: room.id, mode: 'admin' }) ++ const editor = (await call('create-bot', { name: 'Editor' })).value ++ await call('update-room', { id: room.id, add: [editor.id] }) ++ const state = (await call('state', {})).value ++ const chief = state.mainBotId ++ const writer = state.bots.find(bot => bot.name === 'Writer').id ++ let busy = true ++ let markIdle ++ const idle = new Promise(resolve => { markIdle = resolve }) ++ t.after(() => { ++ busy = false ++ markIdle() ++ }) ++ const resolve = ctx.sessionController.resolveAgent ++ ctx.sessionController.resolveAgent = async sessionId => sessionId !== room.id ? resolve(sessionId) : ({ ++ agent: { session: { id: sessionId }, get status() { return busy ? 'running' : 'idle' }, whenIdle: () => idle, followup: () => {} }, ++ }) ++ sent.length = 0 ++ // A Bot posts with report_back, so the closing report comes back to it. Its line names only Writer, so the ++ // Editor is not in the round's order: it can only be called by the line Writer posts while it works. ++ await emit(room.id, { type: 'user/message', data: { source: { kind: 'bot', role: 'post', senderSessionId: chief, senderName: 'Chief', reportBack: true }, content: [{ type: 'text', text: '@Writer check the draft' }] } }) ++ const controller = new AbortController() ++ t.after(() => controller.abort()) ++ const running = relay(room.id, 'r932-report', controller.signal) ++ const first = await turnFor(sent) ++ assert.equal(first.sessionId, rt.roomOf(room.id).sessions?.[writer], 'the named Writer answers') ++ // Writer names the Editor while its own turn runs: the call lands in this round. ++ await rt.postToRoom(rt.roomOf(room.id), { id: writer, name: 'Writer' }, '@Editor check the notes.', { late: true, origin: 'other' }) ++ await answer(first.sessionId, first.message.source.exchangeId, 'Draft is in the folder.', 1) ++ const second = await turnFor(sent, first) ++ assert.ok(second, 'the mid-round call reaches the Editor inside this round') ++ assert.notEqual(second.sessionId, first.sessionId, 'the Editor, not Writer again') ++ assert.match(promptOf(second), /Writer mentioned you/) ++ await answer(second.sessionId, second.message.source.exchangeId, '[PASS]', 2) ++ JSON.parse(await running) ++ const report = await waitFor(() => sent.filter(entry => entry.message?.source?.role === 'report')[0]) ++ const text = report?.message.content[0].text ?? '' ++ assert.match(text, /Called, but no answer: Editor\./, 'a member called mid-round that said nothing is reported as called') ++ assert.doesNotMatch(text, /Stayed silent: [^.]*Editor/, 'never in the sentence for a member nobody called') ++})