Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/932-room-calls-mid-round.fixed.md
Original file line number Diff line number Diff line change
@@ -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)).
35 changes: 33 additions & 2 deletions core/src/host/groups.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions core/src/host/turns.js
Original file line number Diff line number Diff line change
Expand Up @@ -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') } : {}),
Expand Down
171 changes: 169 additions & 2 deletions core/test/rounds.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -1670,3 +1670,170 @@ 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')
})

// #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')
})
Loading
Loading