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/475-face-room-cross-home-answers.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Face-to-face rooms: when a friend's Bot has already answered or posted a plan for a line, your Bots no longer have to answer it again (no forced "nobody answered" reply), a Bot that still speaks to a line for everyone adds only its own part, and a friend's Bot writing `@all` / `@所有人` no longer wakes all of your Bots; its `@YourBot` still calls that Bot ([#475](https://github.com/ThinkFlowLab/FlyBot/issues/475)).
41 changes: 31 additions & 10 deletions core/src/host/groups.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,19 @@ const lastPersonLine = (room) => {
return undefined
}
const lastPersonSeq = room => lastPersonLine(room)?.seq ?? 0
// Another home's Bots that posted while this round answers the person's line (#475): each home runs its own round
// over its own Bots, so a friend's Bot that already answered or planned counts as this line's answer here too. A
// Bot post queued before the round started is not counted: it may be older than the line it is logged after.
const friendAnswers = (room, round) => {
if (round.faceLine === undefined) return []
const after = Math.max(round.faceLine, round.openedSeq ?? 0)
const names = []
for (const line of room.log ?? []) {
if ((line.seq ?? 0) <= after || line.kind || line.botId === undefined || line.botId === 'face-notice' || room.members.includes(line.botId)) continue
if (!names.includes(line.who)) names.push(line.who)
}
return names
}
const FACE_ASKED_KEPT = 24
const currentFaceLine = (room, seq) => (room.faceLine !== undefined && room.faceLine.seq === seq && lastPersonSeq(room) === seq ? room.faceLine : undefined)
// `authorId`: a friend's line is only tracked once it is in the log (applyFaceLine logs it first).
Expand Down Expand Up @@ -205,6 +218,7 @@ export function install(rt) {
const everyone = room.members.map(rt.botOf).filter(Boolean)
const roles = everyone.filter(bot => bot.role).map(bot => `${bot.name}${bot.id === member.id ? ' (you)' : ''}: ${String(bot.role).slice(0, 80)}`)
const friends = currentParticipants(room).filter(p => p.kind === 'bot' && p.remote).map(p => p.label || p.displayName).filter(Boolean)
const answered = friendAnswers(room, round)
return [
'This line names no Bot, and the group should get the right answer, not one from everyone. Decide whether it is yours.',
broadcast
Expand All @@ -213,6 +227,7 @@ export function install(rt) {
last && !broadcast ? `${last.name} spoke last before this line: a follow-up to what ${last.name} said ("what did you mean", "and then?", "say more") is for ${last.name}.` : '',
roles.length > 0 ? `What members do: ${roles.join('; ')}.` : '',
friends.length > 0 ? `Other owners' Bots here answer for themselves in their own home: ${friends.join(', ')}.` : '',
answered.length > 0 ? `${answered.join(', ')} already answered this line or posted a plan; add only your role's part, and do not re-plan or repeat it.` : '',
'[PASS] posts nothing and nobody sees it: never explain a pass.',
].filter(Boolean).join(' ')
}
Expand Down Expand Up @@ -348,15 +363,19 @@ export function install(rt) {
return speakers
}

// A routed, non-broadcast round where a Bot already posted, is working on it or waits for the owner's approval.
const answeredAlready = round => round.route !== undefined && !round.route.broadcast && round.route.forced === undefined
&& round.slots.some(slot => slot.status === 'posted' || slot.status === 'working' || slot.status === 'waiting-approval')
// A routed, non-broadcast round where a Bot already posted, is working on it or waits for the owner's approval,
// or where another home's Bot already answered the line (#475).
const answeredAlready = (room, round) => round.route !== undefined && !round.route.broadcast && round.route.forced === undefined
&& (round.slots.some(slot => slot.status === 'posted' || slot.status === 'working' || slot.status === 'waiting-approval')
|| friendAnswers(room, round).length > 0)

// True once a routed round ended with every Bot silent on the owner's own line (a friend's line is not
// forced: the friend's own home answers it). A line that follows up a friend's Bot is theirs to answer.
function fallbackDue(round) {
// forced: the friend's own home answers it). A line that follows up a friend's Bot is theirs to answer, and
// a line a friend's Bot already answered is answered (#475).
function fallbackDue(room, round) {
const route = round.route
if (!route?.fallback || route.forced !== undefined || (route.last?.remote && !route.broadcast)) return false
if (friendAnswers(room, round).length > 0) return false
const states = round.slots.map(slot => slot.status)
return !states.some(status => status === 'posted' || status === 'working' || status === 'waiting-approval' || status === 'speaking' || status === 'waiting')
&& states.some(status => status === 'passed' || status === 'missed')
Expand Down Expand Up @@ -396,11 +415,11 @@ export function install(rt) {
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(round)) && 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) {
if (signal.aborted) break
limit += drainRoomInbox(room, round, turns, index, agent)
// 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(round)) {
if (index >= round.slots.length && fallbackDue(room, round)) {
const first = round.slots.find(slot => slot.status === 'passed' || slot.status === 'missed')
round.route.forced = first.id
round.slots.push({ id: first.id, name: first.name, status: 'waiting', note: 'nobody answered yet' })
Expand All @@ -416,7 +435,7 @@ export function install(rt) {
}
// One answer for a line that is not for everyone (#299): once a Bot has answered, the Bots still waiting are not
// asked at all (no model call, no line), so three Bots never post three similar answers.
if (answeredAlready(round) || currentFaceLine(room, round.faceLine)?.answered.includes(member.id)) {
if (answeredAlready(room, round) || currentFaceLine(room, round.faceLine)?.answered.includes(member.id)) {
slot.status = 'passed'
round.passed.push(member.name)
continue
Expand Down Expand Up @@ -544,6 +563,7 @@ export function install(rt) {
members: room.members,
...(route ? { route } : {}),
startSeq,
openedSeq: room.seq,
...(opener.kickoff ? { kickoff: true } : {}),
...(references.length > 0 ? { references, shown: [] } : {}),
slots: speakers.map(member => ({
Expand Down Expand Up @@ -736,8 +756,9 @@ export function install(rt) {
}
}
if (found.length > 0 && !all) return found
// Face-to-face Bot posts: a plain one stays in the room; only @Name / @all calls a Bot (#167).
if (isFaceRoom(room) && !all) return []
// Face-to-face Bot posts: a plain one stays in the room; only @Name / @all calls a Bot (#167). Another
// home's Bot's @all wakes none of mine (#475): only the local Bots it names answer.
if (isFaceRoom(room) && (!all || (opener.botId !== undefined && !room.members.includes(opener.botId)))) return found
if (!all && room.mode === 'admin' && admin) return [admin]
return admin ? [admin, ...members.filter(bot => bot !== admin)] : members
}
Expand Down
75 changes: 75 additions & 0 deletions core/test/rounds.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1140,3 +1140,78 @@ test('#455: another person\'s line and another home\'s Bot line that arrive mid-
assert.equal(again.replies.filter(reply => reply.kind === 'user' || reply.kind === 'remote').length, 0)
assert.equal(hosted.log.filter(line => line.authorId === '@researcher.selfb.home').length, 1)
})

// The group's Session is in a turn while its round runs, so another home's Bot line shows and is logged at once (#455).
function busyHost(t, ctx, hosted) {
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 !== hosted.id ? resolve(sessionId) : ({
agent: { session: { id: sessionId }, get status() { return busy ? 'running' : 'idle' }, whenIdle: () => idle, followup: () => {} },
})
}
const friendBotSays = (rt, hosted, text) => rt.postToRoom(hosted, { id: '@chief.bob.home', name: "Bob's Chief" }, text, { remote: { handle: '@chief.bob.home' }, senderName: "Bob's Chief" })
const friendBotPost = text => ({ type: 'user/message', data: { source: { kind: 'bot', role: 'post', senderSessionId: '@chief.bob.home', senderName: "Bob's Chief", remote: { handle: '@chief.bob.home' } }, content: [{ type: 'text', text }] } })

test('#475: a friend\'s Bot that answers my plain line counts: the other Bots are not asked and none is forced to answer', async (t) => {
const env = start()
const { emit, userMessage, relay, answer, stop, sent, rt, ctx } = env
t.after(stop)
const { hosted } = await routedRoom(env, 'cross')
busyHost(t, ctx, hosted)
await emit(hosted.id, userMessage('帮我们订个周五的会议室'))
const running = relay(hosted.id, 'cross-1')
const first = await turnFor(sent)
await friendBotSays(rt, hosted, 'Plan: I book room 3 for Friday 10:00 and send the invite.')
await answer(first.sessionId, first.message.source.exchangeId, '[PASS]', 1)
const done = JSON.parse(await running)
assert.equal(roomTurns(sent).length, 1, 'the next Bot is not asked and nobody is forced: the friend\'s Bot answered')
assert.deepEqual(done.replies.filter(reply => reply.kind === undefined), [], 'no local post')
assert.equal(hosted.log.filter(line => line.botId !== undefined && hosted.members.includes(line.botId)).length, 0)
})

test('#475: a Bot still asked after a friend\'s Bot posted a plan is told to add only its role\'s part', async (t) => {
const env = start()
const { emit, userMessage, relay, answer, stop, sent, rt, ctx } = env
t.after(stop)
const { hosted } = await routedRoom(env, 'plan')
busyHost(t, ctx, hosted)
await emit(hosted.id, userMessage('大家说说这次活动各自负责什么'))
const running = relay(hosted.id, 'plan-1')
const first = await turnFor(sent)
assert.doesNotMatch(promptOf(first), /already answered this line/)
await friendBotSays(rt, hosted, 'Plan: I book the venue, Writer drafts the invite.')
await answer(first.sessionId, first.message.source.exchangeId, 'I will track the budget.', 1)
const second = await turnFor(sent, first)
assert.ok(second, 'a line to everyone still reaches the next Bot')
assert.match(promptOf(second), /Chief \(Bob\) already answered this line or posted a plan; add only your role's part, and do not re-plan or repeat it\./)
await answer(second.sessionId, second.message.source.exchangeId, 'I draft the invite.', 2)
await running
})

test('#475: a friend\'s Bot @all / @所有人 wakes none of my Bots; its @MyBot wakes only that Bot', async (t) => {
const env = start()
const { emit, relay, answer, stop, sent } = env
t.after(stop)
const { hosted, writer } = await routedRoom(env, 'botall')
for (const [at, text] of ['@all please each report your status', '@所有人 汇报一下进度'].entries()) {
sent.length = 0
await emit(hosted.id, friendBotPost(text))
const done = JSON.parse(await relay(hosted.id, `botall-${at}`))
assert.equal(roomTurns(sent).length, 0, `${text}: no local Bot turn`)
assert.deepEqual(done.replies.filter(reply => reply.kind === undefined), [])
}
sent.length = 0
await emit(hosted.id, friendBotPost('@Writer can you draft the invite?'))
const named = relay(hosted.id, 'botall-named')
const only = await turnFor(sent)
assert.equal(only.sessionId, hosted.sessions[writer])
await answer(only.sessionId, only.message.source.exchangeId, 'Drafting it now.', 1)
await named
assert.equal(roomTurns(sent).length, 1, 'only the named Bot')
})
Loading
Loading