diff --git a/changelog.d/1646-room-busy-ack.fixed.md b/changelog.d/1646-room-busy-ack.fixed.md new file mode 100644 index 000000000..cc26d53a3 --- /dev/null +++ b/changelog.d/1646-room-busy-ack.fixed.md @@ -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. diff --git a/core/client.js b/core/client.js index c21486ffb..2136cb947 100644 --- a/core/client.js +++ b/core/client.js @@ -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"); @@ -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)( diff --git a/core/src/client/busy-ack-logic.js b/core/src/client/busy-ack-logic.js index 4a665bcd8..ce89dc862 100644 --- a/core/src/client/busy-ack-logic.js +++ b/core/src/client/busy-ack-logic.js @@ -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 +} diff --git a/core/src/client/busy-ack.js b/core/src/client/busy-ack.js index d88908352..ad309e9de 100644 --- a/core/src/client/busy-ack.js +++ b/core/src/client/busy-ack.js @@ -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 @@ -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', { diff --git a/core/src/host/busy-ack.js b/core/src/host/busy-ack.js index 2cd736d31..f15e603a4 100644 --- a/core/src/host/busy-ack.js +++ b/core/src/host/busy-ack.js @@ -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 '' @@ -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() @@ -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') { @@ -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, }) diff --git a/core/src/host/channel.js b/core/src/host/channel.js index d7d51fb34..95a36c048 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -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 } : {}), diff --git a/core/test/busy-ack-client.test.js b/core/test/busy-ack-client.test.js index 71f9a6d3c..b7e8603b3 100644 --- a/core/test/busy-ack-client.test.js +++ b/core/test/busy-ack-client.test.js @@ -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) @@ -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) +}) diff --git a/core/test/busy-ack.test.js b/core/test/busy-ack.test.js index 9700f81d4..677a1f1f6 100644 --- a/core/test/busy-ack.test.js +++ b/core/test/busy-ack.test.js @@ -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 = '收到,我先记着,手头忙完再详细回你。' @@ -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']) +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 03bb91062..6d790375d 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -122,6 +122,14 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second When a human message is accepted while the Bot is already running (DSH queue or steer, including long background job waits), `BusyAckBubble` (`src/client/busy-ack.js`, mounted from `HeaderPill` next to the activity row) draws one short bot bubble promptly: host live `busyAcks` from `busy-ack.js` on the host, or a local pending submission with placement `queued` / `steering` (`busy-ack-logic.js`). The text is the host's composed line (topic + running tool, message language, never the previous one — [#1629](https://github.com/ThinkFlowLab/FlyBot/issues/1629)); with only a local pending submission (the host ack not here yet) the bubble holds the Bot's mark and a short blinking indicator, no fixed sentence, and the host's line replaces it when it lands ([#1656](https://github.com/ThinkFlowLab/FlyBot/issues/1656)). It hides once spoken assistant text streams. Related but separate: [#1509](https://github.com/ThinkFlowLab/FlyBot/issues/1509) (Steer does not abort a tool wait). Covered by `core/test/busy-ack-client.test.js`. +A group room is acknowledged where its round is drawn (#1646). The open chat is the room, not the member's Session, so `BusyAckBubble` reads `roomBusyAck(room)` — the newest entry of `room.busyAcks` — and draws it as one bot bubble in the room flow's running row (`[data-chat-running]`) under that member's `BotMark`. Three rules decide what it shows: + +- **Only a running member's turn.** The host publishes `room.busyAcks` per member id through `roomBusyAcksView(sessions)`, which names only members whose turn is still running; a room with no such entry draws nothing. A member whose turn is running before its line is composed is published with an empty text, and the room draws the same blinking indicator an own-chat pending ack draws ([#1656](https://github.com/ThinkFlowLab/FlyBot/issues/1656)) — the mark and the dots, no sentence — until the host's line replaces it. On the host the ack is cleared by `turn/end` and by `agent/status` `idle`, so a finished or idle member loses the row instead of leaving a stale line. +- **Only a person's line.** A room line counts when `isHumanRoomLine(source)` holds: `role: 'room'` with no `senderSessionId` (the Bot's own call), no `askedBy` (another Bot's @-mention) and no `remote` (a friend's Bot). A Bot talking in the room is acknowledged by nothing. +- **The person's words, not the relay header.** The host strips the `[Group chat "…" · new message from the user]` header `delivery.js` puts on a room line before composing, so the ack quotes what the person typed. + +The member's own chat keeps its `busyAcks[sessionId]` bubble and its visibility rules unchanged; that path and its line are untouched. The fixed fallback sentence `#1584` used is gone (#1656), in the room as in the own chat: a pending ack is the blinking indicator, and the host's composed line is the text. The host side (`isHumanRoomLine`, the relay-header strip, `roomBusyAcksView`, `channel.js` `busyAcks`) is in `core-host.md`. + ### Face-room held bubble and overview - A face-to-face own message uses `data-held` on `.bt-umsg` while that room's `pendingCount > 0`: dashed, mixed user-surface, `--bt-ink` (not the finished sent user bubble). Incoming lines are unchanged. Face-to-face `GroupOverview` hides the Usage row. diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 2587bd2b8..160bde3ce 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -405,7 +405,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi - A friend's line in a Bot's own chat (#420): `deliver()` (`noteFriendLine`) stamps the receiving Bot with `friendLineAt` (published in the roster, so the save moves the revision) and keeps the senders seen per Bot in `state.remoteContacts[botId][handle]` (host only, capped at 200, dropped with the Bot). A sender's first line carries `source.remote.firstContact`. Group lines, and re-deliveries (`deferred`, `resumed`, `retried`, `resent`), stamp nothing. Covered by `core/test/flybot.test.js`. ## Busy-send acknowledgment (#1584) -When a human `user/message` lands in a Bot's own chat while that Session already has a live turn, the host records a short spoken acknowledgment on the live payload (`busyAcks`). The line is composed on the host with no model call ([#1629](https://github.com/ThinkFlowLab/FlyBot/issues/1629)): it echoes a clipped topic from the message verbatim — a `{t}` / `{d}` the person typed is quoted as typed, not treated as markup ([#1634](https://github.com/ThinkFlowLab/FlyBot/issues/1634)) — and the running tool in words (`bash` → running a command), follows the message's language, rotates its wording, and never repeats the chat's previous ack. The client draws it as one bot bubble (and also from a local pending queue/steer submission) so the chat is not silent while a blocking job wait holds the next step. Related but separate: [#1509](https://github.com/ThinkFlowLab/FlyBot/issues/1509) (Steer does not abort a tool wait). Host: `core/src/host/busy-ack.js`. Client: `core/src/client/busy-ack.js`. Tests: `core/test/busy-ack.test.js`, `core/test/busy-ack-client.test.js`. +When a human `user/message` lands in a Bot's own chat while that Session already has a live turn, the host records a short spoken acknowledgment on the live payload (`busyAcks`); a person's line relayed into a running member's group Session (`role: 'room'`, no Bot sender) earns the same one, keyed by that member Session and published per member on the room as `busyAcks` so the room draws it on the member's row ([#1646](https://github.com/ThinkFlowLab/FlyBot/issues/1646)). The line is composed on the host with no model call ([#1629](https://github.com/ThinkFlowLab/FlyBot/issues/1629)): it echoes a clipped topic from the message verbatim — a `{t}` / `{d}` the person typed is quoted as typed, not treated as markup ([#1634](https://github.com/ThinkFlowLab/FlyBot/issues/1634)) — and the running tool in words (`bash` → running a command), follows the message's language, rotates its wording, and never repeats the chat's previous ack. The client draws it as one bot bubble (and also from a local pending queue/steer submission) so the chat is not silent while a blocking job wait holds the next step. Related but separate: [#1509](https://github.com/ThinkFlowLab/FlyBot/issues/1509) (Steer does not abort a tool wait). Host: `core/src/host/busy-ack.js`. Client: `core/src/client/busy-ack.js`. Tests: `core/test/busy-ack.test.js`, `core/test/busy-ack-client.test.js`. ## Steering interrupt: a wait over 60s yields to a human send (#1509) diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 0538906c1..a4e11eba5 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -288,7 +288,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js const invalid = message => Object.assign(new Error(message), { code: 'bot/invalid' }) // Programming and file system errors are DS Bot's own faults, logged with their stack; -@@ -21,32 +21,149 @@ export function install(rt) { +@@ -21,32 +21,151 @@ export function install(rt) { // The DSH interface language of the last browser that asked for the team. let uiLocale @@ -429,6 +429,8 @@ diff --git a/src/host/channel.js b/src/host/channel.js + ...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 } : {}), diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index cf545dfff..fe58d4536 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -5180,8 +5180,13 @@ diff --git a/client.js b/client.js - ) : null, - foot + error ? (0, import_react8.createElement)("div", { className: "bt-flybot-approval-error", role: "alert" }, error) : null ++ ); ++ return (0, import_react8.createElement)( ++ "div", ++ { className: "bt-held-actions", "data-flybot": "held-reply-actions" }, ++ custom ? (0, import_react8.createElement)(boundary(), { id: custom.id, fallback: plain }, (0, import_react8.createElement)(custom.component, { held, decide, call: call3, actions })) : plain ); --} + } -function SettledQuestion({ question, answer }) { - const options = optionsOf(question); - const prompt = question?.question ?? ""; @@ -5199,12 +5204,6 @@ diff --git a/client.js b/client.js - ); - } - return (0, import_react4.createElement)( -+ return (0, import_react8.createElement)( -+ "div", -+ { className: "bt-held-actions", "data-flybot": "held-reply-actions" }, -+ custom ? (0, import_react8.createElement)(boundary(), { id: custom.id, fallback: plain }, (0, import_react8.createElement)(custom.component, { held, decide, call: call3, actions })) : plain -+ ); -+} + +// src/client/turn-reasoning.js +function turnReasoning(steps, current) { @@ -5787,6 +5786,9 @@ diff --git a/client.js b/client.js - const long = value.trim().length >= MIN_VALUE; - const save = () => { - if (long && !busy) void run(actions.secretSet({ requestId: request.id, value, scope })); +- }; +- const allow = () => { +- if (!busy) void run(actions.secretAllow(request.id)); + try { + const got = await call(`flybot/${plugin}/attachment`, { id: file.id, botId }); + const value = got?.value ?? got; @@ -5801,10 +5803,43 @@ diff --git a/client.js b/client.js + setBusy(""); + } }; -- const allow = () => { -- if (!busy) void run(actions.secretAllow(request.id)); +- const filling = request.kind === "fill" || replacing; +- const warn = insecure() ? (0, import_react5.createElement)("div", { className: "bt-secret-warn", role: "note" }, (0, import_react5.createElement)(WarnIcon), t("This page is not on HTTPS: the key crosses the network unencrypted.")) : null; +- return (0, import_react5.createElement)( + return (0, import_react9.createElement)( -+ "div", + "div", +- { className: "bt-q bt-secret", role: "group", "aria-describedby": noteId, "data-kind": request.kind }, +- (0, import_react5.createElement)(SecretHead, { +- bot, +- title: request.kind === "allow" ? t("Let {bot} use {name}?", { bot: bot.name, name: request.name }) : t("{name} needs a key", { name: bot.name }), +- detail: request.purpose, +- name: request.name, +- onClose: busy ? void 0 : cancel +- }), +- request.kind === "allow" ? (0, import_react5.createElement)("div", { className: "bt-secret-saved" }, (0, import_react5.createElement)(KeyIcon), t("Saved for {scope}", { scope: scopeLabel(secret?.scope, roster) })) : null, +- filling ? (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-body" }, +- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value of {name}", { name: request.name }), autoFocus: replacing })), +- request.kind === "fill" ? (0, import_react5.createElement)(ScopeSwitch, { value: scope, onChange: setScope, bot }) : null +- ) : null, +- (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-note", id: noteId }, +- t("{bot} never sees the value. It reaches {bot}'s shell as ", { bot: bot.name }), +- (0, import_react5.createElement)("code", null, `$DSH_SECRET_${request.name}`), +- ".", +- filling && value !== "" && !long ? t(" At least {count} characters.", { count: MIN_VALUE }) : "" +- ), +- warn, +- error ? (0, import_react5.createElement)("div", { className: "bt-secret-error", role: "alert" }, error) : null, +- (0, import_react5.createElement)( +- "div", +- { className: "bt-q-foot" }, +- request.kind === "allow" && !replacing ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => setReplacing(true) }, t("Replace value")) : null, +- (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: cancel }, t("Cancel")), +- filling ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: !long || busy, onClick: save }, busy ? t("Saving…") : t("Save")) : (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: allow }, busy ? t("Allowing…") : t("Allow")) +- ) + { className: "bt-file-chips" }, + files.map((file) => (0, import_react9.createElement)( + "button", @@ -5812,11 +5847,17 @@ diff --git a/client.js b/client.js + `📎 ${file.name} · ${fileSize(file.size)}` + )), + error ? (0, import_react9.createElement)("span", { className: "bt-file-error", role: "alert" }, error) : null -+ ); -+} + ); + } +-function SettledSecret({ bot, request, roster }) { +- const canceled = request.status === "canceled"; +- const text = canceled ? t("{bot} asked for {name}", { bot: bot.name, name: request.name }) : request.status === "allowed" ? t("Allowed {bot} to use {name}", { bot: bot.name, name: request.name }) : t("Saved {name}", { name: request.name }); +- return (0, import_react5.createElement)( +function GroupEvent({ prefix, room: room2, name, roster, actions }) { + return (0, import_react9.createElement)( -+ "div", + "div", +- { className: "bt-q bt-q-settled bt-secret bt-secret-done", role: "group", "aria-label": text, "data-status": request.status }, +- (0, import_react5.createElement)( + { className: "bt-event" }, + (0, import_react9.createElement)("span", null, prefix), + room2 ? (0, import_react9.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react9.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react9.createElement)("span", null, name) : null @@ -5865,10 +5906,7 @@ diff --git a/client.js b/client.js + busy.current = false; + if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); + }); - }; -- const filling = request.kind === "fill" || replacing; -- const warn = insecure() ? (0, import_react5.createElement)("div", { className: "bt-secret-warn", role: "note" }, (0, import_react5.createElement)(WarnIcon), t("This page is not on HTTPS: the key crosses the network unencrypted.")) : null; -- return (0, import_react5.createElement)( ++ }; + (0, import_react9.useEffect)(() => { + alive.current = true; + load(void 0); @@ -5898,43 +5936,23 @@ diff --git a/client.js b/client.js + else rows.push(item.kind === "bot" ? { kind: "bot", texts: [item.text] } : item); + } + return (0, import_react9.createElement)( - "div", -- { className: "bt-q bt-secret", role: "group", "aria-describedby": noteId, "data-kind": request.kind }, -- (0, import_react5.createElement)(SecretHead, { -- bot, -- title: request.kind === "allow" ? t("Let {bot} use {name}?", { bot: bot.name, name: request.name }) : t("{name} needs a key", { name: bot.name }), -- detail: request.purpose, -- name: request.name, -- onClose: busy ? void 0 : cancel -- }), -- request.kind === "allow" ? (0, import_react5.createElement)("div", { className: "bt-secret-saved" }, (0, import_react5.createElement)(KeyIcon), t("Saved for {scope}", { scope: scopeLabel(secret?.scope, roster) })) : null, -- filling ? (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-body" }, -- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value of {name}", { name: request.name }), autoFocus: replacing })), -- request.kind === "fill" ? (0, import_react5.createElement)(ScopeSwitch, { value: scope, onChange: setScope, bot }) : null -- ) : null, -- (0, import_react5.createElement)( ++ "div", + { className: "bt-earlier", role: "feed", "aria-label": t("Earlier messages"), "aria-busy": view.loading }, + (0, import_react9.createElement)( "div", -- { className: "bt-secret-note", id: noteId }, -- t("{bot} never sees the value. It reaches {bot}'s shell as ", { bot: bot.name }), -- (0, import_react5.createElement)("code", null, `$DSH_SECRET_${request.name}`), -- ".", -- filling && value !== "" && !long ? t(" At least {count} characters.", { count: MIN_VALUE }) : "" +- { className: "bt-q-head" }, +- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 22 })), +- (0, import_react5.createElement)("div", { className: "bt-q-text" }, (0, import_react5.createElement)( +- "p", +- { className: "bt-q-title" }, +- text, +- canceled ? null : (0, import_react5.createElement)("span", { className: "bt-secret-scope-tag" }, ` · ${scopeLabel(request.scope, roster)}`) +- )), +- canceled ? (0, import_react5.createElement)("span", { className: "bt-q-badge" }, t("Canceled")) : (0, import_react5.createElement)("span", { className: "bt-q-check bt-secret-check", title: t("Done") }, (0, import_react5.createElement)(CheckIcon)) +- ) + { ref: top, className: "bt-earlier-top" }, + view.loading ? (0, import_react9.createElement)(Shimmer, null, t("Loading earlier messages…")) : view.failed ? (0, import_react9.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Couldn’t load earlier messages · Retry")) : view.cursor ? (0, import_react9.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Load earlier")) : null - ), -- warn, -- error ? (0, import_react5.createElement)("div", { className: "bt-secret-error", role: "alert" }, error) : null, -- (0, import_react5.createElement)( -- "div", -- { className: "bt-q-foot" }, -- request.kind === "allow" && !replacing ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => setReplacing(true) }, t("Replace value")) : null, -- (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: cancel }, t("Cancel")), -- filling ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: !long || busy, onClick: save }, busy ? t("Saving…") : t("Save")) : (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: allow }, busy ? t("Allowing…") : t("Allow")) -- ) ++ ), + rows.map((row, index) => { + const key = rows.length - index; + switch (row.kind) { @@ -5966,25 +5984,8 @@ diff --git a/client.js b/client.js + (0, import_react9.createElement)(PartLine, { time: view.startedAt }) ); } --function SettledSecret({ bot, request, roster }) { -- const canceled = request.status === "canceled"; -- const text = canceled ? t("{bot} asked for {name}", { bot: bot.name, name: request.name }) : request.status === "allowed" ? t("Allowed {bot} to use {name}", { bot: bot.name, name: request.name }) : t("Saved {name}", { name: request.name }); -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-q bt-q-settled bt-secret bt-secret-done", role: "group", "aria-label": text, "data-status": request.status }, -- (0, import_react5.createElement)( -- "div", -- { className: "bt-q-head" }, -- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 22 })), -- (0, import_react5.createElement)("div", { className: "bt-q-text" }, (0, import_react5.createElement)( -- "p", -- { className: "bt-q-title" }, -- text, -- canceled ? null : (0, import_react5.createElement)("span", { className: "bt-secret-scope-tag" }, ` · ${scopeLabel(request.scope, roster)}`) -- )), -- canceled ? (0, import_react5.createElement)("span", { className: "bt-q-badge" }, t("Canceled")) : (0, import_react5.createElement)("span", { className: "bt-q-check bt-secret-check", title: t("Done") }, (0, import_react5.createElement)(CheckIcon)) -- ) -- ); +-function RequestView({ bot, request, roster, actions }) { +- return request.status === "pending" ? (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }) : (0, import_react5.createElement)(SettledSecret, { bot, request, roster }); +function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { + const roster = useRoster((value) => value); + const data = node.data; @@ -6067,7 +6068,15 @@ diff --git a/client.js b/client.js +} +function NewDivider() { + return (0, import_react9.createElement)("div", { className: "bt-new-sep", role: "separator", "aria-label": t("New messages") }, (0, import_react9.createElement)("span", null, t("New"))); -+} + } +-function SecretToolCell({ sessionId, callId, name, done, content, roster, actions }) { +- const bot = roster.byId[sessionId]; +- if (!bot) return null; +- const request = (roster.secretRequests?.[bot.id] ?? []).find((entry) => entry.callId === callId && (entry.sessionId ?? sessionId) === sessionId); +- if (request) return (0, import_react5.createElement)("div", { className: "bt-q-inline" }, (0, import_react5.createElement)(RequestView, { bot, request, roster, actions })); +- if (done && /already saved/.test(contentText(content))) { +- return (0, import_react5.createElement)("div", { className: "bt-event" }, (0, import_react5.createElement)("span", { className: "bt-secret-event" }, (0, import_react5.createElement)(KeyIcon), t("{bot} is using {name}", { bot: bot.name, name: name ?? t("a saved key") }))); +- } +function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTurnClock, actions }) { + const open = turnProcess === void 0 || !turnProcess.foldable || turnProcess.open; + (0, import_react9.useEffect)(() => { @@ -6085,19 +6094,20 @@ diff --git a/client.js b/client.js + return !hidden && typeof visit?.seen === "number" && own?.first !== void 0 && own.first <= visit.seen && own.end !== void 0 && own.end > visit.seen && own.end <= visit.entered; + }); + return fresh ? (0, import_react9.createElement)(NewDivider) : null; - } --function RequestView({ bot, request, roster, actions }) { -- return request.status === "pending" ? (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }) : (0, import_react5.createElement)(SettledSecret, { bot, request, roster }); ++} +function Nothing() { -+ return null; + return null; } --function SecretToolCell({ sessionId, callId, name, done, content, roster, actions }) { +-function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { +- const roster = useRoster((value) => value); +- const shown = useCards?.((state) => state[sessionId]) ?? NONE; +- const loading = useSession?.(historyLoading) === true; - const bot = roster.byId[sessionId]; -- if (!bot) return null; -- const request = (roster.secretRequests?.[bot.id] ?? []).find((entry) => entry.callId === callId && (entry.sessionId ?? sessionId) === sessionId); -- if (request) return (0, import_react5.createElement)("div", { className: "bt-q-inline" }, (0, import_react5.createElement)(RequestView, { bot, request, roster, actions })); -- if (done && /already saved/.test(contentText(content))) { -- return (0, import_react5.createElement)("div", { className: "bt-event" }, (0, import_react5.createElement)("span", { className: "bt-secret-event" }, (0, import_react5.createElement)(KeyIcon), t("{bot} is using {name}", { bot: bot.name, name: name ?? t("a saved key") }))); +- if (!bot || currentPart(bot) !== sessionId) return null; +- const drawn = (entry) => entry.callId && entry.sessionId === sessionId && (loading || shown[entry.callId] > 0); +- const waiting = (roster.secretRequests?.[bot.id] ?? []).filter((entry) => entry.status === "pending" && !drawn(entry)); +- if (waiting.length === 0) return null; +- return (0, import_react5.createElement)("div", { className: "bt-q-dock bt-secret-dock" }, waiting.map((request) => (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }))); +var HIDDEN_REMOVED_TOOLS = /* @__PURE__ */ new Set(["ask_user_question"]); +var isConnectorTool = (name) => typeof name === "string" && /^mcp__(github|composio)__/.test(name); +var SILENT_REGISTRATION_TOOLS = /* @__PURE__ */ new Set([ @@ -6146,32 +6156,10 @@ diff --git a/client.js b/client.js + if (isHiddenNotice(block)) continue; + if (block?.type === "tool-removal" && block.toolName) parts.push(`Tool removed: ${block.toolName}`); + else if (block?.type === "tool-addition" && block.toolName) parts.push(`Tool added: ${block.toolName}`); - } -- return null; ++ } + if (parts.length === 0) return null; + return (0, import_react9.createElement)("div", { className: "bt-event" }, parts.join(" · ")); } --function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { -- const roster = useRoster((value) => value); -- const shown = useCards?.((state) => state[sessionId]) ?? NONE; -- const loading = useSession?.(historyLoading) === true; -- const bot = roster.byId[sessionId]; -- if (!bot || currentPart(bot) !== sessionId) return null; -- const drawn = (entry) => entry.callId && entry.sessionId === sessionId && (loading || shown[entry.callId] > 0); -- const waiting = (roster.secretRequests?.[bot.id] ?? []).filter((entry) => entry.status === "pending" && !drawn(entry)); -- if (waiting.length === 0) return null; -- return (0, import_react5.createElement)("div", { className: "bt-q-dock bt-secret-dock" }, waiting.map((request) => (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }))); -+var SEPARATOR_GAP_MS = 30 * 60 * 1e3; -+function clockLabel(time, now = Date.now()) { -+ const date = new Date(time); -+ const midnight = (value) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); -+ const days = Math.round((midnight(new Date(now)) - midnight(date)) / 864e5); -+ const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); -+ if (days === 0) return t("Today {time}", { time: clock4 }); -+ if (days === 1) return t("Yesterday {time}", { time: clock4 }); -+ const sameYear = date.getFullYear() === new Date(now).getFullYear(); -+ return `${date.toLocaleDateString(dateLocale(), { weekday: "short", month: "short", day: "numeric", ...sameYear ? {} : { year: "numeric" } })} ${clock4}`; - } -function ScopeEditor({ secret, roster, onSave }) { - const [mode, setMode] = (0, import_react5.useState)(secret.scope === "all" ? "all" : "some"); - const [picked, setPicked] = (0, import_react5.useState)(secret.scope === "all" ? [] : secret.scope); @@ -6204,13 +6192,16 @@ diff --git a/client.js b/client.js - (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: same, onClick: () => onSave(mode === "all" ? "all" : picked) }, t("Save access")) - ) - ); -+function nextTurnStart(turns, turn) { -+ let next; -+ for (const key in turns) { -+ const candidate = Number(key); -+ if (candidate > turn && turns[key].first !== void 0 && (next === void 0 || candidate < next)) next = candidate; -+ } -+ return next === void 0 ? void 0 : turns[next].first; ++var SEPARATOR_GAP_MS = 30 * 60 * 1e3; ++function clockLabel(time, now = Date.now()) { ++ const date = new Date(time); ++ const midnight = (value) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); ++ const days = Math.round((midnight(new Date(now)) - midnight(date)) / 864e5); ++ const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); ++ if (days === 0) return t("Today {time}", { time: clock4 }); ++ if (days === 1) return t("Yesterday {time}", { time: clock4 }); ++ const sameYear = date.getFullYear() === new Date(now).getFullYear(); ++ return `${date.toLocaleDateString(dateLocale(), { weekday: "short", month: "short", day: "numeric", ...sameYear ? {} : { year: "numeric" } })} ${clock4}`; } -function ValueEditor({ secret, onSave }) { - const [value, setValue] = (0, import_react5.useState)(""); @@ -6241,14 +6232,13 @@ diff --git a/client.js b/client.js - ), - value !== "" && !long ? (0, import_react5.createElement)("div", { className: "bt-set-hint" }, t("At least {count} characters.", { count: MIN_VALUE })) : replaced ? (0, import_react5.createElement)("div", { className: "bt-set-hint bt-secret-ok", role: "status" }, (0, import_react5.createElement)(CheckIcon), t("Replaced. Bots get the new value from their next command.")) : null - ); -+function openingTurn(turns, firstTurns, hiddenTurns) { -+ if (!turns || !firstTurns) return null; -+ const start = nextTurnStart(turns, -Infinity); -+ if (start === void 0) return null; -+ const turn = Number(Object.keys(turns).filter((key) => turns[key].first === start)[0]); -+ const listed = firstTurns.split(",").map(Number); -+ const earlier = listed.filter((entry) => entry < turn); -+ return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; ++function nextTurnStart(turns, turn) { ++ let next; ++ for (const key in turns) { ++ const candidate = Number(key); ++ if (candidate > turn && turns[key].first !== void 0 && (next === void 0 || candidate < next)) next = candidate; ++ } ++ return next === void 0 ? void 0 : turns[next].first; } -function SecretRow({ secret, roster, actions, run, open, onToggle }) { - const [confirm, setConfirm] = (0, import_react5.useState)(false); @@ -6292,14 +6282,14 @@ diff --git a/client.js b/client.js - ) - ) : null - ); -+var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; -+function movedOnSince(visit, turns, hiddenTurns) { -+ if (typeof visit?.entered !== "number") return false; -+ for (const key in turns ?? {}) { -+ if (hiddenTurns?.includes(Number(key))) continue; -+ if (turns[key].first !== void 0 && turns[key].first > visit.entered) return true; -+ } -+ return false; ++function openingTurn(turns, firstTurns, hiddenTurns) { ++ if (!turns || !firstTurns) return null; ++ const start = nextTurnStart(turns, -Infinity); ++ if (start === void 0) return null; ++ const turn = Number(Object.keys(turns).filter((key) => turns[key].first === start)[0]); ++ const listed = firstTurns.split(",").map(Number); ++ const earlier = listed.filter((entry) => entry < turn); ++ return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; } -function AddSecret({ actions, run, onDone }) { - const [name, setName] = (0, import_react5.useState)(""); @@ -6328,6 +6318,15 @@ diff --git a/client.js b/client.js - (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDone }, t("Cancel")), - (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !ready, onClick: save }, t("Save key")) - ) ++var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; ++function movedOnSince(visit, turns, hiddenTurns) { ++ if (typeof visit?.entered !== "number") return false; ++ for (const key in turns ?? {}) { ++ if (hiddenTurns?.includes(Number(key))) continue; ++ if (turns[key].first !== void 0 && turns[key].first > visit.entered) return true; ++ } ++ return false; ++} +var settledVisit = (visit) => typeof visit?.seen === "number" && visit.seen < visit.entered ? { ...visit, seen: visit.entered } : visit; +function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) { + const turn = location2.turn; @@ -10306,7 +10305,7 @@ diff --git a/client.js b/client.js @keyframes bt-update-pop{from{opacity:0;transform:scale(.96);filter:blur(4px)}} .bt-update-title{font-size:14px;line-height:20px;font-weight:600} .bt-update-text{margin-top:2px;font-size:12.5px;line-height:18px;color:var(--bt-ink-2)} -@@ -5491,18 +5491,128 @@ var SIDEBAR_CSS = ` +@@ -5491,18 +5491,140 @@ var SIDEBAR_CSS = ` .bt-agent-sub[data-open] .bt-agent-sub-row{animation:bt-agent-row-in .24s ease both;animation-delay:calc(var(--i, 0)*30ms)} @keyframes bt-agent-row-in{from{opacity:0;filter:blur(4px)}to{opacity:1;filter:blur(0)}} @media (prefers-reduced-motion:reduce){.bt-agent-sub{transition:none}.bt-agent-sub .bt-agent-sub-row{animation:none}} @@ -10339,6 +10338,16 @@ diff --git a/client.js b/client.js + 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"); @@ -10371,15 +10380,17 @@ diff --git a/client.js b/client.js +} +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)( diff --git a/patches/ds-bot/files/src/client/busy-ack-logic.js b/patches/ds-bot/files/src/client/busy-ack-logic.js index 4a665bcd8..ce89dc862 100644 --- a/patches/ds-bot/files/src/client/busy-ack-logic.js +++ b/patches/ds-bot/files/src/client/busy-ack-logic.js @@ -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 +} diff --git a/patches/ds-bot/files/src/client/busy-ack.js b/patches/ds-bot/files/src/client/busy-ack.js index d88908352..ad309e9de 100644 --- a/patches/ds-bot/files/src/client/busy-ack.js +++ b/patches/ds-bot/files/src/client/busy-ack.js @@ -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 @@ -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', { diff --git a/patches/ds-bot/files/src/host/busy-ack.js b/patches/ds-bot/files/src/host/busy-ack.js index 2cd736d31..f15e603a4 100644 --- a/patches/ds-bot/files/src/host/busy-ack.js +++ b/patches/ds-bot/files/src/host/busy-ack.js @@ -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 '' @@ -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() @@ -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') { @@ -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, }) diff --git a/patches/ds-bot/files/test/busy-ack-client.test.js b/patches/ds-bot/files/test/busy-ack-client.test.js index 71f9a6d3c..b7e8603b3 100644 --- a/patches/ds-bot/files/test/busy-ack-client.test.js +++ b/patches/ds-bot/files/test/busy-ack-client.test.js @@ -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) @@ -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) +}) diff --git a/patches/ds-bot/files/test/busy-ack.test.js b/patches/ds-bot/files/test/busy-ack.test.js index 9700f81d4..677a1f1f6 100644 --- a/patches/ds-bot/files/test/busy-ack.test.js +++ b/patches/ds-bot/files/test/busy-ack.test.js @@ -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 = '收到,我先记着,手头忙完再详细回你。' @@ -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']) +})