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/1646-room-busy-ack.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A member Bot busy with a tool in a group chat now acknowledges a person's line there, the same one short line the own-chat path already speaks (`#1646`). The ack is keyed by the member's room Session and drawn on that member's row in the room flow — the host's line, or the same blinking indicator #1656 draws while that line is still on its way, never a fixed sentence; a line that reaches no running member, and every own-chat case, is unchanged.
18 changes: 15 additions & 3 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -9867,6 +9867,16 @@ function busyAckVisible({ hostAck, pendingBusy, streamingText }) {
if (streamingText) return false;
return busyAckText(hostAck) !== "" || pendingBusy === true;
}
function roomBusyAck(room2) {
const entries2 = Object.entries(room2?.busyAcks ?? {}).filter(([, ack2]) => ack2 !== void 0 && ack2 !== null);
if (entries2.length === 0) return null;
const [botId, ack] = entries2.sort((a, b) => (b[1].at ?? 0) - (a[1].at ?? 0))[0];
return { botId, text: busyAckText(ack), at: ack.at ?? 0 };
}
function roomBusyAckVisible({ ack, pendingBusy }) {
if (ack === null || ack === void 0) return false;
return ack.text !== "" || pendingBusy === true;
}

// src/client/busy-ack.js
var import_react17 = require("react");
Expand Down Expand Up @@ -9898,15 +9908,17 @@ function useRunningHost2(active) {
}
function BusyAckBubble({ sessionId, useRoster, useBusyAcks, useSessionStatus, useSession, useLive }) {
const roster = useRoster((value) => value);
const room2 = roster.roomsById?.[sessionId];
const memberAck = room2 ? roomBusyAck(room2) : null;
const hostAck = useBusyAcks((map) => map?.[sessionId] ?? null);
const running = useSessionStatus((map) => map.get(sessionId)?.running === true);
const pendingBusy = useSession((snapshot) => pendingBusyPlacement(snapshot?.pendingSubmissions));
const streamingText = useLive((state) => Object.values(state[sessionId] ?? {}).some((entry) => entry?.text));
const visible = running && busyAckVisible({ hostAck, pendingBusy, streamingText });
const bot = roster.byId[sessionId];
const visible = room2 ? roomBusyAckVisible({ ack: memberAck, pendingBusy }) : running && busyAckVisible({ hostAck, pendingBusy, streamingText });
const bot = room2 ? roster.byId[memberAck?.botId] : roster.byId[sessionId];
const host = useRunningHost2(visible);
const shown = (0, import_react17.useRef)(null);
if (visible) shown.current = { text: busyAckText(hostAck), at: hostAck?.at ?? Date.now() };
if (visible) shown.current = room2 ? { text: memberAck?.text ?? "", at: memberAck?.at ?? Date.now() } : { text: busyAckText(hostAck), at: hostAck?.at ?? Date.now() };
const text = shown.current?.text ?? "";
if (!visible || !host || !shown.current || !bot) return null;
return (0, import_react_dom4.createPortal)((0, import_react17.createElement)(
Expand Down
18 changes: 18 additions & 0 deletions core/src/client/busy-ack-logic.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,21 @@ export function busyAckVisible({ hostAck, pendingBusy, streamingText }) {
if (streamingText) return false
return busyAckText(hostAck) !== '' || pendingBusy === true
}

// The ack a room flow draws on its member's row (#1646). The host publishes `room.busyAcks`
// keyed by the member while that member's turn runs in the room; an entry whose text is still
// empty means the host's line is not composed yet, which the client draws the way #1656 draws
// a pending own-chat ack: the mark and the blinking indicator, no fixed sentence.
export function roomBusyAck(room) {
const entries = Object.entries(room?.busyAcks ?? {}).filter(([, ack]) => ack !== undefined && ack !== null)
if (entries.length === 0) return null
const [botId, ack] = entries.sort((a, b) => (b[1].at ?? 0) - (a[1].at ?? 0))[0]
return { botId, text: busyAckText(ack), at: ack.at ?? 0 }
}

// A room's ack is drawn while that member's turn runs: the host's line as text, or the blinking
// indicator alone while the person's line is still queued in the room and the line is not here.
export function roomBusyAckVisible({ ack, pendingBusy }) {
if (ack === null || ack === undefined) return false
return ack.text !== '' || pendingBusy === true
}
20 changes: 15 additions & 5 deletions core/src/client/busy-ack.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import { createElement as h, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { BotMark } from './mark.js'
import { RUNNING_HOST } from './activity-row.js'
import { busyAckText, busyAckVisible, pendingBusyPlacement } from './busy-ack-logic.js'
import { busyAckText, busyAckVisible, pendingBusyPlacement, roomBusyAck, roomBusyAckVisible } from './busy-ack-logic.js'

export { busyAckPending, busyAckText, busyAckVisible, pendingBusyPlacement } from './busy-ack-logic.js'
export {
busyAckPending, busyAckText, busyAckVisible, pendingBusyPlacement, roomBusyAck, roomBusyAckVisible,
} from './busy-ack-logic.js'

// Spoken busy-ack (#1584): when a human send is accepted while the Bot is running
// (host live busyAcks, or a local pending queue/steer submission), show one short
Expand Down Expand Up @@ -32,15 +34,23 @@ function useRunningHost(active) {

export function BusyAckBubble({ sessionId, useRoster, useBusyAcks, useSessionStatus, useSession, useLive }) {
const roster = useRoster(value => value)
const room = roster.roomsById?.[sessionId]
// A group draws one member's ack on the row that member's turn runs in (#1646); its
// Session is not the conversation being shown, so `busyAcks[sessionId]` never has it.
const memberAck = room ? roomBusyAck(room) : null
const hostAck = useBusyAcks(map => map?.[sessionId] ?? null)
const running = useSessionStatus(map => map.get(sessionId)?.running === true)
const pendingBusy = useSession(snapshot => pendingBusyPlacement(snapshot?.pendingSubmissions))
const streamingText = useLive(state => Object.values(state[sessionId] ?? {}).some(entry => entry?.text))
const visible = running && busyAckVisible({ hostAck, pendingBusy, streamingText })
const bot = roster.byId[sessionId]
const visible = room
? roomBusyAckVisible({ ack: memberAck, pendingBusy })
: running && busyAckVisible({ hostAck, pendingBusy, streamingText })
const bot = room ? roster.byId[memberAck?.botId] : roster.byId[sessionId]
const host = useRunningHost(visible)
const shown = useRef(null)
if (visible) shown.current = { text: busyAckText(hostAck), at: hostAck?.at ?? Date.now() }
if (visible) shown.current = room
? { text: memberAck?.text ?? '', at: memberAck?.at ?? Date.now() }
: { text: busyAckText(hostAck), at: hostAck?.at ?? Date.now() }
const text = shown.current?.text ?? ''
if (!visible || !host || !shown.current || !bot) return null
return createPortal(h('div', {
Expand Down
37 changes: 31 additions & 6 deletions core/src/host/busy-ack.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,19 @@ export function shouldNoteBusyAck({ sourceKind, turnRunning, ownChat }) {
return sourceKind === 'user' && turnRunning === true && ownChat === true
}

// A person's line reaching a member's group Session (#1646). A round relays it as
// `role: 'room'` with no Bot sender: a Bot's own call carries `senderSessionId`, an
// @-mention by another Bot carries `askedBy`, and a friend's Bot carries `remote`.
export function isHumanRoomLine(source) {
return source?.role === 'room'
&& source.senderSessionId === undefined
&& source.askedBy === undefined
&& source.remote === undefined
}

// The relay prefix delivery.js puts on the line; the ack quotes the person's text, not it.
const relayHeader = /^\[[^\]\n]*\]\r?\n/

const textOf = (content) => {
if (typeof content === 'string') return content
if (!Array.isArray(content)) return ''
Expand All @@ -145,6 +158,9 @@ export function install(rt) {

const turnRunning = sessionId => rt.live?.get(sessionId)?.turn !== undefined
const ownChat = sessionId => rt.chatOwners?.has(sessionId) === true
const groupSession = sessionId => rt.groupOwners?.has(sessionId) === true
/** A person's line into this member's running group Session (#1646), once only. */
const roomLine = (sessionId, source) => groupSession(sessionId) && isHumanRoomLine(source)

const note = (sessionId, message) => {
const now = Date.now()
Expand Down Expand Up @@ -173,12 +189,12 @@ export function install(rt) {
const id = session?.id
if (id === undefined || event?.type === undefined) return
if (event.type === 'user/message') {
if (!shouldNoteBusyAck({
sourceKind: event.data?.source?.kind,
turnRunning: turnRunning(id),
ownChat: ownChat(id),
})) return
note(id, textOf(event.data?.content ?? event.data?.message?.content))
const source = event.data?.source
const room = roomLine(id, source)
const own = shouldNoteBusyAck({ sourceKind: source?.kind, turnRunning: turnRunning(id), ownChat: ownChat(id) })
if (!own && !(room && turnRunning(id))) return
const text = textOf(event.data?.content ?? event.data?.message?.content)
note(id, room ? text.replace(relayHeader, '') : text)
return
}
if (event.type === 'assistant/message') {
Expand All @@ -196,6 +212,15 @@ export function install(rt) {

Object.assign(rt, {
busyAcksView: () => Object.fromEntries([...acks].map(([id, ack]) => [id, { text: ack.text, at: ack.at }])),
// A room draws the ack on its running member's row (#1646), so its flow is keyed by
// member, not by the Session a person cannot see. A member whose turn runs before its
// line is composed is published with an empty text: the client draws the same blinking
// indicator an own-chat pending ack draws (#1656), never a fixed sentence.
roomBusyAcksView: sessions => Object.fromEntries(Object.entries(sessions ?? {}).flatMap(([botId, sessionId]) => {
if (!turnRunning(sessionId)) return []
const ack = acks.get(sessionId)
return [[botId, ack === undefined ? { text: '', at: 0 } : { text: ack.text, at: ack.at }]]
})),
noteBusyAck: note,
clearBusyAck: clear,
})
Expand Down
2 changes: 2 additions & 0 deletions core/src/host/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@ export function install(rt) {
...presented,
log: undefined,
parts,
// A member's busy ack, for the row that member's turn draws in the room flow (#1646).
busyAcks: rt.roomBusyAcksView?.(sessions) ?? {},
pendingApprovals: pending,
pendingCount: pending.length,
...(last ? { preview: previewWho } : {}),
Expand Down
28 changes: 27 additions & 1 deletion core/test/busy-ack-client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { readFileSync } from 'node:fs'
import { test } from 'node:test'
import * as logic from '../src/client/busy-ack-logic.js'

const { busyAckPending, busyAckText, busyAckVisible, pendingBusyPlacement } = logic
const {
busyAckPending, busyAckText, busyAckVisible, pendingBusyPlacement, roomBusyAck, roomBusyAckVisible,
} = logic

test('pendingBusyPlacement is true for queued or steering submissions', () => {
assert.equal(pendingBusyPlacement([]), false)
Expand Down Expand Up @@ -43,3 +45,27 @@ test('#1656: the canned sentence is not shipped anywhere in the client', () => {
assert.match(bubble, /'aria-busy': text \? undefined : 'true'/)
assert.match(bubble, /@keyframes bt-busy-blink/)
})

test('a room ack is the running member\'s, keyed by the member the host noted (#1646)', () => {
assert.equal(roomBusyAck(undefined), null)
assert.equal(roomBusyAck({}), null)
assert.equal(roomBusyAck({ busyAcks: {} }), null)
assert.deepEqual(roomBusyAck({ busyAcks: { b: { text: '收到', at: 7 } } }), { botId: 'b', text: '收到', at: 7 })
// The host publishes the member as soon as its turn runs; empty text means the ack line is
// still being composed, and the client draws the pending dots for it (#1656, #1646).
assert.deepEqual(roomBusyAck({ busyAcks: { b: { text: '', at: 1 } } }), { botId: 'b', text: '', at: 1 })
assert.equal(roomBusyAck({ busyAcks: { b: { text: ' ', at: 1 } } }).text, '')
// Only the speaking member has one; the newest wins if two ever land together.
assert.deepEqual(
roomBusyAck({ busyAcks: { a: { text: 'older', at: 5 }, b: { text: 'newer', at: 9 } } }),
{ botId: 'b', text: 'newer', at: 9 },
)
})

test('a room ack shows its line, or the blinking dots while the person\'s line is still queued (#1646)', () => {
assert.equal(roomBusyAckVisible({ ack: null, pendingBusy: true }), false)
assert.equal(roomBusyAckVisible({ ack: { botId: 'b', text: '', at: 1 }, pendingBusy: false }), false)
assert.equal(roomBusyAckVisible({ ack: { botId: 'b', text: '', at: 1 }, pendingBusy: true }), true)
assert.equal(roomBusyAckVisible({ ack: { botId: 'b', text: '收到', at: 1 }, pendingBusy: false }), true)
assert.equal(roomBusyAckVisible({ ack: { botId: 'b', text: '收到', at: 1 }, pendingBusy: true }), true)
})
140 changes: 139 additions & 1 deletion core/test/busy-ack.test.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from 'node:test'
import {
ackLanguage, composeBusyAck, doingOf, shouldNoteBusyAck, topicOf,
ackLanguage, composeBusyAck, doingOf, install as installBusyAck, shouldNoteBusyAck, topicOf,
} from '../src/host/busy-ack.js'
import { install as installStore } from '../src/host/store.js'

// The pre-#1629 canned lines; an ack must never be either verbatim.
const OLD_ZH = '收到,我先记着,手头忙完再详细回你。'
Expand Down Expand Up @@ -151,3 +155,137 @@ test('a {t} typed in the message is quoted back verbatim, with and without a run
}
}
})

// --- The room side (#1646) -------------------------------------------------
// A group's member speaks from a hidden Session (`room.sessions[botId]`), and the person's line
// reaches it as a relayed `role: 'room'` message, not as `source.kind === 'user'`. These tests
// run the real store beside the real busy-ack install, so `chatOwners` and `groupOwners` are the
// host's own indexes; only `live` is seeded, as turns.js would have recorded a running turn.
const roomHost = async (t) => {
const dir = mkdtempSync(join(tmpdir(), 'ds-bot-busy-ack-room-'))
const statePath = join(dir, 'state.json')
writeFileSync(statePath, JSON.stringify({
version: 2,
mainBotIds: ['a'],
members: { 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'a' } },
bots: {
a: { id: 'a', name: 'Chief', sessionId: 'a', ownerId: 'owner-1', createdAt: 1 },
b: { id: 'b', name: 'Writer', sessionId: 'b', ownerId: 'owner-1', createdAt: 2 },
},
rooms: {
r1: {
id: 'r1', name: 'Ops', members: ['a', 'b'], mode: 'admin', admin: 'b', named: true,
sessions: { b: 'gb' }, log: [], seq: 0, seen: {},
},
},
agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, revision: 0,
}))
const disposers = []
const listeners = new Map()
const ctx = {
logger: { warn() {}, info() {} },
effect(execute) { const dispose = execute(); if (typeof dispose === 'function') disposers.push(dispose) },
on(name, fn) { const list = listeners.get(name) ?? listeners.set(name, []).get(name); list.push(fn); return () => {} },
}
const rt = {
ctx, home: dir, statePath,
autoRoomName: () => 'Room', retitleRoom: async () => {}, dropGroupSession: () => {}, warn: () => {},
}
installStore(rt)
await rt.load()
rt.live = new Map()
installBusyAck(rt)
t.after(() => {
for (const dispose of disposers.splice(0)) dispose()
rmSync(dir, { recursive: true, force: true, maxRetries: 5 })
})
const fire = (name, ...args) => { for (const fn of listeners.get(name) ?? []) fn(...args) }
const emit = (id, type, data) => fire('session/event', { id }, { type, seq: 1, data })
return { rt, emit, fire, chatOwners: rt.chatOwners, groupOwners: rt.groupOwners }
}

// The relay a person's line takes into a member's group Session (delivery.js + groups.js).
const roomSay = text => ({
type: 'user/message',
data: {
source: { kind: 'bot', form: 'relay', role: 'room', roomId: 'r1', exchangeId: 'x1', senderName: 'You', hop: 0 },
content: [{ type: 'text', text: `[Group chat "Ops" · new message from the user]\n${text}` }],
},
})

test('the indexes the room case leans on are the store\'s own (#1646)', async (t) => {
const host = await roomHost(t)
assert.equal(host.chatOwners.get('b'), 'b')
assert.equal(host.chatOwners.has('gb'), false)
assert.deepEqual(host.groupOwners.get('gb'), { botId: 'b', roomId: 'r1' })
})

test('a room line steered into a running member\'s Session notes one ack for that Session (#1646)', async (t) => {
const host = await roomHost(t)
host.rt.live.set('gb', { turn: 1, tool: 'bash' })
host.emit('gb', 'turn/start', { turn: 1 })
host.emit('gb', 'user/message', roomSay('帮我把周报再改一下').data)
const acks = host.rt.busyAcksView()
assert.deepEqual(Object.keys(acks), ['gb'])
assert.match(acks.gb.text, /周报/)
assert.match(acks.gb.text, /跑命令/)
assert.doesNotMatch(acks.gb.text, /Group chat/)
// Coalesced: a second line inside the window keeps the one ack.
const at = acks.gb.at
host.emit('gb', 'user/message', roomSay('again').data)
assert.deepEqual(Object.keys(host.rt.busyAcksView()), ['gb'])
assert.equal(host.rt.busyAcksView().gb.text, acks.gb.text)
assert.ok(host.rt.busyAcksView().gb.at >= at)
// No ack survives the turn.
host.emit('gb', 'turn/end', { turn: 1, reason: { kind: 'completed' } })
assert.deepEqual(host.rt.busyAcksView(), {})
host.rt.live.set('gb', { turn: 2 })
host.emit('gb', 'user/message', roomSay('还在吗').data)
assert.ok(host.rt.busyAcksView().gb)
host.fire('agent/status', { agent: { session: { id: 'gb' } }, status: 'idle' })
assert.deepEqual(host.rt.busyAcksView(), {})
})

test('a room line to an idle member, and a line another Bot relayed, note nothing (#1646)', async (t) => {
const host = await roomHost(t)
host.emit('gb', 'user/message', roomSay('没有 turn').data)
assert.deepEqual(host.rt.busyAcksView(), {})
host.rt.live.set('gb', { turn: 1 })
host.emit('gb', 'turn/start', { turn: 1 })
const fromBot = roomSay('another Bot called you')
fromBot.data.source.senderSessionId = 'a'
fromBot.data.source.askedBy = 'a'
host.emit('gb', 'user/message', fromBot.data)
assert.deepEqual(host.rt.busyAcksView(), {})
})

test('the room view names a running member before its line lands, then carries the line (#1646)', async (t) => {
const host = await roomHost(t)
host.rt.live.set('gb', { turn: 1 })
host.emit('gb', 'turn/start', { turn: 1 })
// The member's turn is already running while the person's line is still being composed:
// an empty text, which the client draws as the pending blinking indicator, no sentence.
assert.deepEqual(host.rt.roomBusyAcksView({ b: 'gb' }), { b: { text: '', at: 0 } })
host.emit('gb', 'user/message', roomSay('帮我把周报再改一下').data)
assert.match(host.rt.roomBusyAcksView({ b: 'gb' }).b.text, /周报/)
assert.doesNotMatch(host.rt.roomBusyAcksView({ b: 'gb' }).b.text, /Group chat/)
// No running turn, no entry; an empty member map stays empty.
host.rt.live.delete('gb')
assert.deepEqual(host.rt.roomBusyAcksView({ b: 'gb' }), {})
assert.deepEqual(host.rt.roomBusyAcksView({}), {})
})

test('the own-chat ack is unchanged beside the room path (#1646)', async (t) => {
const host = await roomHost(t)
host.rt.live.set('b', { turn: 1, tool: 'bash' })
host.emit('b', 'turn/start', { turn: 1 })
host.emit('b', 'user/message', say('check the logs').data)
const acks = host.rt.busyAcksView()
assert.deepEqual(Object.keys(acks), ['b'])
assert.match(acks.b.text, /check the logs/)
// The same Session id space: a room line in the member's group Session is its own key.
host.rt.live.set('gb', { turn: 1 })
host.emit('gb', 'turn/start', { turn: 1 })
host.emit('gb', 'user/message', roomSay('帮我看日志').data)
assert.deepEqual(Object.keys(host.rt.busyAcksView()).sort(), ['b', 'gb'])
})
Loading
Loading