From 6b0a3a67fbb50b8a5eaaaa497609d0e831beae79 Mon Sep 17 00:00:00 2001 From: Birfy Date: Thu, 29 Oct 2026 11:18:59 +0800 Subject: [PATCH 1/2] fix(chat): busy-ack composes a natural line from the message and running tool (#1629) --- changelog.d/1629-busy-ack-natural.fixed.md | 1 + core/client.js | 4 +- core/src/client/busy-ack-logic.js | 2 +- core/src/client/i18n.js | 2 +- core/src/host/busy-ack.js | 152 +++++++++++++++-- core/test/busy-ack-client.test.js | 2 +- core/test/busy-ack.test.js | 154 ++++++++++-------- docs/subsystems/core-host.md | 2 +- patches/ds-bot/0001-core-fixes.patch | 2 +- patches/ds-bot/0008-client-bundle.patch | 4 +- .../ds-bot/files/src/client/busy-ack-logic.js | 2 +- patches/ds-bot/files/src/host/busy-ack.js | 152 +++++++++++++++-- .../ds-bot/files/test/busy-ack-client.test.js | 2 +- patches/ds-bot/files/test/busy-ack.test.js | 154 ++++++++++-------- 14 files changed, 470 insertions(+), 165 deletions(-) create mode 100644 changelog.d/1629-busy-ack-natural.fixed.md diff --git a/changelog.d/1629-busy-ack-natural.fixed.md b/changelog.d/1629-busy-ack-natural.fixed.md new file mode 100644 index 000000000..3d73df06a --- /dev/null +++ b/changelog.d/1629-busy-ack-natural.fixed.md @@ -0,0 +1 @@ +The busy-ack bubble no longer repeats one canned sentence. While a Bot is working, a new message gets a short one-line ack composed on the spot from what you wrote and what the Bot is doing (e.g. 「看到你说「帮我改周报」了,我正在跑命令,弄完马上接上。」), in the language of your message, with varied wording that never repeats the previous ack — still instant, no extra model call ([#1629](https://github.com/ThinkFlowLab/FlyBot/issues/1629)). diff --git a/core/client.js b/core/client.js index 8e3df0021..526721fdc 100644 --- a/core/client.js +++ b/core/client.js @@ -838,7 +838,7 @@ var ZH = { "Message actions": "消息操作", Download: "下载", "Thought it through": "思考过程", - "Got it — I’ll answer properly once I finish what’s in progress.": "收到,我先记着,手头忙完再详细回你。", + "Seen — I’ll get to this right after the current step.": "看到了,这步做完就接着处理你这条。", "Earlier messages": "更早的消息", "Loading earlier messages…": "正在加载更早的消息…", "Couldn’t load earlier messages · Retry": "更早的消息没加载出来 · 重试", @@ -9815,7 +9815,7 @@ var SIDEBAR_CSS = ` `; // src/client/busy-ack-logic.js -var BUSY_ACK_KEY = "Got it — I’ll answer properly once I finish what’s in progress."; +var BUSY_ACK_KEY = "Seen — I’ll get to this right after the current step."; function pendingBusyPlacement(submissions) { if (!Array.isArray(submissions)) return false; return submissions.some((item) => item?.placement === "queued" || item?.placement === "steering"); diff --git a/core/src/client/busy-ack-logic.js b/core/src/client/busy-ack-logic.js index da5eb8b9d..628689872 100644 --- a/core/src/client/busy-ack-logic.js +++ b/core/src/client/busy-ack-logic.js @@ -1,6 +1,6 @@ // Pure busy-ack helpers (#1584). Kept free of React so unit tests import them without a DOM. -export const BUSY_ACK_KEY = 'Got it — I’ll answer properly once I finish what’s in progress.' +export const BUSY_ACK_KEY = 'Seen — I’ll get to this right after the current step.' export function pendingBusyPlacement(submissions) { if (!Array.isArray(submissions)) return false diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index 4b88d93fe..72d01720e 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -785,7 +785,7 @@ const ZH = { 'Message actions': '消息操作', Download: '下载', 'Thought it through': '思考过程', - 'Got it — I’ll answer properly once I finish what’s in progress.': '收到,我先记着,手头忙完再详细回你。', + 'Seen — I’ll get to this right after the current step.': '看到了,这步做完就接着处理你这条。', 'Earlier messages': '更早的消息', 'Loading earlier messages…': '正在加载更早的消息…', 'Couldn’t load earlier messages · Retry': '更早的消息没加载出来 · 重试', diff --git a/core/src/host/busy-ack.js b/core/src/host/busy-ack.js index 99b741544..2e2cca0a6 100644 --- a/core/src/host/busy-ack.js +++ b/core/src/host/busy-ack.js @@ -2,43 +2,171 @@ // gets a short spoken acknowledgment on the live payload immediately. Steer admits // to next-step, but a blocking job wait may never yield a step (#1509), so without // this the chat stays silent until the wait ends. -export const BUSY_ACK_EN = 'Got it — I’ll answer properly once I finish what’s in progress.' -export const BUSY_ACK_ZH = '收到,我先记着,手头忙完再详细回你。' +// +// The line is composed here, deterministically, from the message itself and what the +// turn is doing right now (its running tool), not a fixed sentence (#1629). A model +// call would wait behind the very turn that is busy, cost a call per message and need +// a fallback anyway; a template is instant, free and cannot fail. Wording rotates and +// never repeats the previous ack of the same chat; the language follows the message. export const COALESCE_MS = 3_000 +export const TOPIC_MAX = { zh: 16, en: 36 } -export const busyAckText = (language) => (/chinese|简体|\bzh\b/i.test(String(language ?? '')) ? BUSY_ACK_ZH : BUSY_ACK_EN) +const CJK = /[\u3400-\u9fff\uf900-\ufaff]/g + +/** 'zh' when the message is mostly Chinese, 'en' when mostly Latin, else the UI language. */ +export function ackLanguage(text, uiLanguage) { + const value = String(text ?? '') + const cjk = (value.match(CJK) ?? []).length + const latin = (value.match(/[A-Za-z]/g) ?? []).length + if (cjk > 0 && cjk * 2 >= latin) return 'zh' + if (latin > 0) return 'en' + return /chinese|简体|\bzh\b/i.test(String(uiLanguage ?? '')) ? 'zh' : 'en' +} + +/** A short topic out of the message: first line, trimmed, clipped; '' when too thin to echo. */ +export function topicOf(text, lang) { + const line = String(text ?? '').split(/\r?\n/).map(part => part.trim()).find(Boolean) ?? '' + const clean = line.replace(/\s+/g, ' ').replace(/^[@#]\S+\s*/, '').replace(/[。!?!?.,,、~~…]+$/u, '').trim() + if (clean === '' || /^(hi|hello|hey|ok|okay|嗯+|好的?|在吗|在不在|\?+|?+)$/i.test(clean)) return '' + const max = TOPIC_MAX[lang] ?? TOPIC_MAX.en + const chars = [...clean] + if (chars.length <= max) return clean + let cut = chars.slice(0, max - 1).join('') + if (lang === 'en' && cut.lastIndexOf(' ') > max / 2) cut = cut.slice(0, cut.lastIndexOf(' ')) + return `${cut.trimEnd()}…` +} + +const TOOL_WORDS = [ + [/^(bash|shell|exec|run|command|terminal)$/i, { zh: '跑命令', en: 'running a command' }], + [/^(wait|job|poll|await)$/i, { zh: '等一个任务跑完', en: 'waiting on a job' }], + [/^(search|grep|glob|find)$/i, { zh: '查资料', en: 'searching' }], + [/^(fetch|browse|browser|web|http|url)$/i, { zh: '看网页', en: 'reading a page' }], + [/^(write|edit|patch|apply)$/i, { zh: '改文件', en: 'editing files' }], + [/^(read|cat|open|view|ls)$/i, { zh: '翻文件', en: 'reading files' }], + [/^(message|delegate|subagent|agent)$/i, { zh: '和别的 Bot 对接', en: 'checking with another Bot' }], + [/^(task|todo|plan)$/i, { zh: '整理任务', en: 'sorting out tasks' }], +] + +/** What the running tool is, in a few words; undefined with no tool. */ +export function doingOf(tool, lang) { + if (typeof tool !== 'string' || tool.trim() === '' || tool === 'ask_user') return undefined + // Match whole name parts (`web_fetch` → web, fetch) so `frobnicate` is not "cat". + const parts = tool.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean) + const hit = TOOL_WORDS.find(([pattern]) => parts.some(part => pattern.test(part)) ) + if (hit) return hit[1][lang] + return lang === 'zh' ? `用 ${tool}` : `using ${tool}` +} + +const TEMPLATES = { + zh: { + topicDoing: [ + '看到你说「{t}」了,我正在{d},弄完马上接上。', + '「{t}」收到,手上在{d},这步完了就来。', + '记下「{t}」了,{d}这边一结束就处理。', + ], + topic: [ + '看到「{t}」了,当前这步一完就回你。', + '「{t}」记下了,马上接着处理。', + '好,「{t}」这个我排上了,稍等一下。', + ], + doing: [ + '看到了,我正在{d},弄完就回你。', + '收到新消息,手上在{d},稍等片刻。', + '嗯,{d}这边一结束就回来。', + ], + bare: [ + '看到了,这步做完就回你。', + '新消息收到,稍等我一下。', + '好的,马上接上你这条。', + ], + }, + en: { + topicDoing: [ + 'Saw "{t}" — I’m {d} right now, back on it in a moment.', + 'Noted "{t}" — still {d}, then it’s next.', + 'Got "{t}" — I’ll get to it once I’m done {d}.', + ], + topic: [ + 'Saw "{t}" — I’ll pick it up right after this step.', + 'Noted "{t}", coming to it shortly.', + '"{t}" is next on my list — one moment.', + ], + doing: [ + 'Seen — I’m {d}, back to you shortly.', + 'New message noted; still {d}, one moment.', + 'On it as soon as I’m done {d}.', + ], + bare: [ + 'Seen — back to you right after this step.', + 'Noted, one moment.', + 'Picking this up as soon as the current step ends.', + ], + }, +} + +/** + * One short ack line for `message` while the turn is doing `tool`. `seed` picks the + * variant; `previous` is never returned again (the next variant is taken instead). + */ +export function composeBusyAck({ message, tool, uiLanguage, seed = 0, previous } = {}) { + const lang = ackLanguage(message, uiLanguage) + const topic = topicOf(message, lang) + const doing = doingOf(tool, lang) + const kind = topic && doing ? 'topicDoing' : topic ? 'topic' : doing ? 'doing' : 'bare' + const pool = TEMPLATES[lang][kind] + const fill = index => pool[index % pool.length].replaceAll('{t}', topic).replaceAll('{d}', doing ?? '') + const start = Math.abs(Math.trunc(seed)) % pool.length + for (let step = 0; step < pool.length; step += 1) { + const text = fill(start + step) + if (text !== previous) return text + } + return fill(start) +} export function shouldNoteBusyAck({ sourceKind, turnRunning, ownChat }) { return sourceKind === 'user' && turnRunning === true && ownChat === true } +const textOf = (content) => { + if (typeof content === 'string') return content + if (!Array.isArray(content)) return '' + return content.filter(block => block?.type === 'text' && typeof block.text === 'string').map(block => block.text).join('') +} + export function install(rt) { const { ctx } = rt /** @type {Map} */ const acks = new Map() + /** Last ack text per chat, kept across clears so consecutive acks differ. */ + const lastText = new Map() + let counter = 0 const turnRunning = sessionId => rt.live?.get(sessionId)?.turn !== undefined const ownChat = sessionId => rt.chatOwners?.has(sessionId) === true - const note = (sessionId) => { + const note = (sessionId, message) => { const now = Date.now() const existing = acks.get(sessionId) if (existing && now - existing.at < COALESCE_MS) { existing.at = now return existing } - const entry = { text: busyAckText(rt.uiLanguage?.()), at: now } + counter += 1 + const text = composeBusyAck({ + message, + tool: rt.live?.get(sessionId)?.tool, + uiLanguage: rt.uiLanguage?.(), + seed: counter + now, + previous: lastText.get(sessionId), + }) + lastText.set(sessionId, text) + const entry = { text, at: now } acks.set(sessionId, entry) return entry } const clear = (sessionId) => { acks.delete(sessionId) } - const textOfAssistant = (content) => { - if (!Array.isArray(content)) return '' - return content.filter(block => block?.type === 'text' && typeof block.text === 'string').map(block => block.text).join('') - } - ctx.on('session/event', (session, event) => { const id = session?.id if (id === undefined || event?.type === undefined) return @@ -48,11 +176,11 @@ export function install(rt) { turnRunning: turnRunning(id), ownChat: ownChat(id), })) return - note(id) + note(id, textOf(event.data?.content ?? event.data?.message?.content)) return } if (event.type === 'assistant/message') { - if (textOfAssistant(event.data?.message?.content).trim() !== '') clear(id) + if (textOf(event.data?.message?.content).trim() !== '') clear(id) return } if (event.type === 'turn/end') clear(id) diff --git a/core/test/busy-ack-client.test.js b/core/test/busy-ack-client.test.js index 785ebaa70..33931a3a5 100644 --- a/core/test/busy-ack-client.test.js +++ b/core/test/busy-ack-client.test.js @@ -18,5 +18,5 @@ test('busyAckVisible prefers host or pending ack and hides once spoken text stre assert.equal(busyAckVisible({ hostAck: { text: '收到' }, pendingBusy: false, streamingText: false }), true) assert.equal(busyAckVisible({ hostAck: null, pendingBusy: true, streamingText: false }), true) assert.equal(busyAckVisible({ hostAck: { text: '收到' }, pendingBusy: true, streamingText: true }), false) - assert.equal(BUSY_ACK_KEY.includes('Got it'), true) + assert.equal(BUSY_ACK_KEY.includes('answer properly once I finish'), false) }) diff --git a/core/test/busy-ack.test.js b/core/test/busy-ack.test.js index 7e62242f3..cb3170fd0 100644 --- a/core/test/busy-ack.test.js +++ b/core/test/busy-ack.test.js @@ -1,13 +1,70 @@ import assert from 'node:assert/strict' import { test } from 'node:test' import { - BUSY_ACK_EN, BUSY_ACK_ZH, busyAckText, shouldNoteBusyAck, + ackLanguage, composeBusyAck, doingOf, shouldNoteBusyAck, topicOf, } from '../src/host/busy-ack.js' -test('busyAckText follows the UI language', () => { - assert.equal(busyAckText('English'), BUSY_ACK_EN) - assert.equal(busyAckText('Chinese (简体中文)'), BUSY_ACK_ZH) - assert.equal(busyAckText('zh-CN'), BUSY_ACK_ZH) +// The pre-#1629 canned lines; an ack must never be either verbatim. +const OLD_ZH = '收到,我先记着,手头忙完再详细回你。' +const OLD_EN = 'Got it — I’ll answer properly once I finish what’s in progress.' + +const harness = (live, uiLanguage = 'English') => { + const listeners = new Map() + const rt = { live, chatOwners: new Map([...live.keys()].map(id => [id, id])), uiLanguage: () => uiLanguage } + const ctx = { on(name, fn) { (listeners.get(name) ?? listeners.set(name, []).get(name)).push(fn) } } + return { rt: Object.assign(rt, { ctx }), emit: (name, ...args) => { for (const fn of listeners.get(name) ?? []) fn(...args) } } +} +const say = text => ({ type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text }] } }) + +test('the ack language follows the message, then the UI language', () => { + assert.equal(ackLanguage('帮我看下日志', 'English'), 'zh') + assert.equal(ackLanguage('check the logs please', 'Chinese (简体中文)'), 'en') + assert.equal(ackLanguage('👍', 'zh-CN'), 'zh') + assert.equal(ackLanguage('', 'English'), 'en') +}) + +test('topicOf clips the first line and drops filler', () => { + assert.equal(topicOf('帮我改周报\n第二行', 'zh'), '帮我改周报') + assert.equal(topicOf('在吗', 'zh'), '') + assert.equal(topicOf('hi', 'en'), '') + const long = topicOf('Can you also check the deploy logs for the staging cluster?', 'en') + assert.ok([...long].length <= 36 && long.endsWith('…'), long) +}) + +test('doingOf names the running tool in words', () => { + assert.equal(doingOf('bash', 'zh'), '跑命令') + assert.equal(doingOf('wait_job', 'en'), 'waiting on a job') + assert.equal(doingOf('ask_user', 'en'), undefined) + assert.equal(doingOf(undefined, 'en'), undefined) + assert.equal(doingOf('frobnicate', 'en'), 'using frobnicate') +}) + +test('composeBusyAck reflects the message and the tool, one line, never the canned sentence', () => { + const zh = composeBusyAck({ message: '帮我把周报的数据图再改一下', tool: 'bash' }) + assert.match(zh, /周报/) + assert.match(zh, /跑命令/) + const en = composeBusyAck({ message: 'Can you check the deploy logs?', tool: 'wait_job' }) + assert.match(en, /deploy logs/) + assert.match(en, /waiting on a job/) + for (const message of ['在吗', 'hello', '帮我查下天气', 'ship it', '']) { + for (const tool of [undefined, 'bash', 'web_fetch']) { + for (let seed = 0; seed < 6; seed += 1) { + const text = composeBusyAck({ message, tool, seed, uiLanguage: 'Chinese' }) + assert.notEqual(text, OLD_ZH) + assert.notEqual(text, OLD_EN) + assert.ok(!text.includes('\n') && [...text].length <= 90, text) + } + } + } +}) + +test('composeBusyAck never repeats the previous ack', () => { + let previous + for (let seed = 0; seed < 10; seed += 1) { + const text = composeBusyAck({ message: '在吗', seed: 0, previous }) + assert.notEqual(text, previous) + previous = text + } }) test('shouldNoteBusyAck only for a human line in an own chat while a turn runs', () => { @@ -18,85 +75,52 @@ test('shouldNoteBusyAck only for a human line in an own chat while a turn runs', assert.equal(shouldNoteBusyAck({ sourceKind: undefined, turnRunning: true, ownChat: true }), false) }) -test('install notes a human message while a turn runs and clears on spoken assistant text', async () => { +test('install composes the ack from the message and the live tool, coalesces, clears on spoken text', async () => { const { install } = await import('../src/host/busy-ack.js') - const listeners = new Map() - const live = new Map([['a', { turn: 1 }]]) - const chatOwners = new Map([['a', 'a']]) - const rt = { - live, - chatOwners, - uiLanguage: () => 'Chinese (简体中文)', - } - const ctx = { - on(name, fn) { - (listeners.get(name) ?? listeners.set(name, []).get(name)).push(fn) - }, - } - install(Object.assign(rt, { ctx })) + const live = new Map([['a', { turn: 1, tool: 'bash' }]]) + const { rt, emit } = harness(live, 'English') + install(rt) assert.deepEqual(rt.busyAcksView(), {}) - for (const fn of listeners.get('session/event')) { - fn({ id: 'a' }, { type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: 'hello' }] } }) - } + emit('session/event', { id: 'a' }, say('帮我把周报再改一下')) const acks = rt.busyAcksView() - assert.equal(acks.a.text, BUSY_ACK_ZH) + assert.match(acks.a.text, /周报/) + assert.match(acks.a.text, /跑命令/) + assert.notEqual(acks.a.text, OLD_ZH) assert.ok(typeof acks.a.at === 'number') - // A second burst within the coalesce window keeps one ack. const at = acks.a.at - for (const fn of listeners.get('session/event')) { - fn({ id: 'a' }, { type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: 'again' }] } }) - } + emit('session/event', { id: 'a' }, say('again')) assert.equal(Object.keys(rt.busyAcksView()).length, 1) + assert.equal(rt.busyAcksView().a.text, acks.a.text) assert.ok(rt.busyAcksView().a.at >= at) - // Idle agent with no turn does not note. live.delete('a') - for (const fn of listeners.get('session/event')) { - fn({ id: 'a' }, { type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: 'idle' }] } }) - } + emit('session/event', { id: 'a' }, say('idle')) assert.ok(rt.busyAcksView().a) - // Spoken assistant text clears the ack. live.set('a', { turn: 1 }) - for (const fn of listeners.get('session/event')) { - fn({ id: 'a' }, { type: 'assistant/message', data: { message: { content: [{ type: 'text', text: '详细回复' }] } } }) - } + emit('session/event', { id: 'a' }, { type: 'assistant/message', data: { message: { content: [{ type: 'text', text: '详细回复' }] } } }) assert.deepEqual(rt.busyAcksView(), {}) }) -test('install clears on turn/end and agent idle', async () => { - const { install, BUSY_ACK_EN } = await import('../src/host/busy-ack.js') - const listeners = new Map() +test('consecutive acks in one chat differ, and clear on turn/end and agent idle', async () => { + const { install } = await import('../src/host/busy-ack.js') const live = new Map([['a', { turn: 2 }]]) - const rt = { - live, - chatOwners: new Map([['a', 'a']]), - uiLanguage: () => 'English', - } - const ctx = { - on(name, fn) { - (listeners.get(name) ?? listeners.set(name, []).get(name)).push(fn) - }, - } - install(Object.assign(rt, { ctx })) - for (const fn of listeners.get('session/event')) { - fn({ id: 'a' }, { type: 'user/message', data: { source: { kind: 'user' } } }) - } - assert.equal(rt.busyAcksView().a.text, BUSY_ACK_EN) - - for (const fn of listeners.get('session/event')) { - fn({ id: 'a' }, { type: 'turn/end', data: { turn: 2 } }) - } - assert.deepEqual(rt.busyAcksView(), {}) - - for (const fn of listeners.get('session/event')) { - fn({ id: 'a' }, { type: 'user/message', data: { source: { kind: 'user' } } }) + const { rt, emit } = harness(live, 'English') + install(rt) + const seen = [] + for (let round = 0; round < 4; round += 1) { + emit('session/event', { id: 'a' }, { type: 'user/message', data: { source: { kind: 'user' } } }) + const text = rt.busyAcksView().a.text + assert.notEqual(text, OLD_EN) + if (seen.length) assert.notEqual(text, seen.at(-1)) + seen.push(text) + emit('session/event', { id: 'a' }, { type: 'turn/end', data: { turn: 2 } }) + assert.deepEqual(rt.busyAcksView(), {}) } + emit('session/event', { id: 'a' }, say('one more')) assert.ok(rt.busyAcksView().a) - for (const fn of listeners.get('agent/status')) { - fn({ agent: { session: { id: 'a' } }, status: 'idle' }) - } + emit('agent/status', { agent: { session: { id: 'a' } }, status: 'idle' }) assert.deepEqual(rt.busyAcksView(), {}) }) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index aaf399208..9b482b879 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 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`). 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 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`. - **Pickup after a restart** (`resume.js`): every running Bot turn and every delivered-but-not-taken message is noted in `inflight.json`. At the next start a noted turn is picked up once only if a restart really cut it: the durable Session log's `turn/end` for that turn is `interrupted` (or an abort caused by `disposed`, or there is no end). A turn that finished while this plugin was not listening — `completed`, `blocked`, `max-tokens`, `forked`, or aborted by the user / hook / parent — is not repeated; a `turn/start` seen after the start means the noted turn ended too. Notes from a build before the record carried `turn` keep the old behaviour. A pickup is confirmed before its note is dropped: the notes read at start are seeded into the live `turns`/`queued` (so the file keeps everything still owed), a turn's note stays while its pickup runs and is dropped only once handed over (unless the resumed turn has written its own note first), and a queued message whose resend fails is kept for the next start, an exchange is noted once (a resend delivers the `exchangeId` the notes already hold, and noting it again left a copy behind after a turn took it up, so the start after that sent the message once more), and a turn taking up a message forgets every note of its exchange; a crash or a failed delivery during the pickup no longer loses the work — a pickup is at-least-once, not once: a crash between handing the turn over and the write that drops its note repeats that pickup once, because the `taken` set and the inbox that would tell the next start otherwise are process-local. That is the safe side of the trade, a repeated turn the user can see instead of work the user was told about going silently dead. Covered by `core/test/follow-through.test.js`. - A repeated Bot-to-Bot line becomes one turn (#641): `deliver()` keys the last 60 s of Bot-to-Bot text by sender + recipient Session + the text with its whitespace collapsed (`DEDUPE_MS`, `duplicateDelivery`), drops a repeat before it is recorded as an exchange or handed to the Session, and journals the drop as `delivery: dedupe -> ` in the host warning log. The key includes the Session, so the same line to another Session (a group copy, another Bot) is its own delivery; `deferred` / `resumed` / `retried` / `resent` carry on a message the Session already has and are never judged, and a friend's line, the user's own message and a group round are not Bot-to-Bot. Routing (parent and direct children) and the existing unread-queue refusal are unchanged, and the map is process-local and capped at 500 entries. Covered by `core/test/repeated-delivery.test.js`. diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 7faaab1d7..7ddb5d698 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -1161,7 +1161,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Message actions': '消息操作', Download: '下载', 'Thought it through': '思考过程', -+ 'Got it — I’ll answer properly once I finish what’s in progress.': '收到,我先记着,手头忙完再详细回你。', ++ 'Seen — I’ll get to this right after the current step.': '看到了,这步做完就接着处理你这条。', 'Earlier messages': '更早的消息', 'Loading earlier messages…': '正在加载更早的消息…', 'Couldn’t load earlier messages · Retry': '更早的消息没加载出来 · 重试', diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index a1b0ca805..beac779df 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -581,7 +581,7 @@ diff --git a/client.js b/client.js "Message actions": "消息操作", Download: "下载", "Thought it through": "思考过程", -+ "Got it — I’ll answer properly once I finish what’s in progress.": "收到,我先记着,手头忙完再详细回你。", ++ "Seen — I’ll get to this right after the current step.": "看到了,这步做完就接着处理你这条。", "Earlier messages": "更早的消息", "Loading earlier messages…": "正在加载更早的消息…", "Couldn’t load earlier messages · Retry": "更早的消息没加载出来 · 重试", @@ -10184,7 +10184,7 @@ diff --git a/client.js b/client.js -// src/client/header.js -var import_react9 = require("react"); +// src/client/busy-ack-logic.js -+var BUSY_ACK_KEY = "Got it — I’ll answer properly once I finish what’s in progress."; ++var BUSY_ACK_KEY = "Seen — I’ll get to this right after the current step."; +function pendingBusyPlacement(submissions) { + if (!Array.isArray(submissions)) return false; + return submissions.some((item) => item?.placement === "queued" || item?.placement === "steering"); 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 da5eb8b9d..628689872 100644 --- a/patches/ds-bot/files/src/client/busy-ack-logic.js +++ b/patches/ds-bot/files/src/client/busy-ack-logic.js @@ -1,6 +1,6 @@ // Pure busy-ack helpers (#1584). Kept free of React so unit tests import them without a DOM. -export const BUSY_ACK_KEY = 'Got it — I’ll answer properly once I finish what’s in progress.' +export const BUSY_ACK_KEY = 'Seen — I’ll get to this right after the current step.' export function pendingBusyPlacement(submissions) { if (!Array.isArray(submissions)) return false diff --git a/patches/ds-bot/files/src/host/busy-ack.js b/patches/ds-bot/files/src/host/busy-ack.js index 99b741544..2e2cca0a6 100644 --- a/patches/ds-bot/files/src/host/busy-ack.js +++ b/patches/ds-bot/files/src/host/busy-ack.js @@ -2,43 +2,171 @@ // gets a short spoken acknowledgment on the live payload immediately. Steer admits // to next-step, but a blocking job wait may never yield a step (#1509), so without // this the chat stays silent until the wait ends. -export const BUSY_ACK_EN = 'Got it — I’ll answer properly once I finish what’s in progress.' -export const BUSY_ACK_ZH = '收到,我先记着,手头忙完再详细回你。' +// +// The line is composed here, deterministically, from the message itself and what the +// turn is doing right now (its running tool), not a fixed sentence (#1629). A model +// call would wait behind the very turn that is busy, cost a call per message and need +// a fallback anyway; a template is instant, free and cannot fail. Wording rotates and +// never repeats the previous ack of the same chat; the language follows the message. export const COALESCE_MS = 3_000 +export const TOPIC_MAX = { zh: 16, en: 36 } -export const busyAckText = (language) => (/chinese|简体|\bzh\b/i.test(String(language ?? '')) ? BUSY_ACK_ZH : BUSY_ACK_EN) +const CJK = /[\u3400-\u9fff\uf900-\ufaff]/g + +/** 'zh' when the message is mostly Chinese, 'en' when mostly Latin, else the UI language. */ +export function ackLanguage(text, uiLanguage) { + const value = String(text ?? '') + const cjk = (value.match(CJK) ?? []).length + const latin = (value.match(/[A-Za-z]/g) ?? []).length + if (cjk > 0 && cjk * 2 >= latin) return 'zh' + if (latin > 0) return 'en' + return /chinese|简体|\bzh\b/i.test(String(uiLanguage ?? '')) ? 'zh' : 'en' +} + +/** A short topic out of the message: first line, trimmed, clipped; '' when too thin to echo. */ +export function topicOf(text, lang) { + const line = String(text ?? '').split(/\r?\n/).map(part => part.trim()).find(Boolean) ?? '' + const clean = line.replace(/\s+/g, ' ').replace(/^[@#]\S+\s*/, '').replace(/[。!?!?.,,、~~…]+$/u, '').trim() + if (clean === '' || /^(hi|hello|hey|ok|okay|嗯+|好的?|在吗|在不在|\?+|?+)$/i.test(clean)) return '' + const max = TOPIC_MAX[lang] ?? TOPIC_MAX.en + const chars = [...clean] + if (chars.length <= max) return clean + let cut = chars.slice(0, max - 1).join('') + if (lang === 'en' && cut.lastIndexOf(' ') > max / 2) cut = cut.slice(0, cut.lastIndexOf(' ')) + return `${cut.trimEnd()}…` +} + +const TOOL_WORDS = [ + [/^(bash|shell|exec|run|command|terminal)$/i, { zh: '跑命令', en: 'running a command' }], + [/^(wait|job|poll|await)$/i, { zh: '等一个任务跑完', en: 'waiting on a job' }], + [/^(search|grep|glob|find)$/i, { zh: '查资料', en: 'searching' }], + [/^(fetch|browse|browser|web|http|url)$/i, { zh: '看网页', en: 'reading a page' }], + [/^(write|edit|patch|apply)$/i, { zh: '改文件', en: 'editing files' }], + [/^(read|cat|open|view|ls)$/i, { zh: '翻文件', en: 'reading files' }], + [/^(message|delegate|subagent|agent)$/i, { zh: '和别的 Bot 对接', en: 'checking with another Bot' }], + [/^(task|todo|plan)$/i, { zh: '整理任务', en: 'sorting out tasks' }], +] + +/** What the running tool is, in a few words; undefined with no tool. */ +export function doingOf(tool, lang) { + if (typeof tool !== 'string' || tool.trim() === '' || tool === 'ask_user') return undefined + // Match whole name parts (`web_fetch` → web, fetch) so `frobnicate` is not "cat". + const parts = tool.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean) + const hit = TOOL_WORDS.find(([pattern]) => parts.some(part => pattern.test(part)) ) + if (hit) return hit[1][lang] + return lang === 'zh' ? `用 ${tool}` : `using ${tool}` +} + +const TEMPLATES = { + zh: { + topicDoing: [ + '看到你说「{t}」了,我正在{d},弄完马上接上。', + '「{t}」收到,手上在{d},这步完了就来。', + '记下「{t}」了,{d}这边一结束就处理。', + ], + topic: [ + '看到「{t}」了,当前这步一完就回你。', + '「{t}」记下了,马上接着处理。', + '好,「{t}」这个我排上了,稍等一下。', + ], + doing: [ + '看到了,我正在{d},弄完就回你。', + '收到新消息,手上在{d},稍等片刻。', + '嗯,{d}这边一结束就回来。', + ], + bare: [ + '看到了,这步做完就回你。', + '新消息收到,稍等我一下。', + '好的,马上接上你这条。', + ], + }, + en: { + topicDoing: [ + 'Saw "{t}" — I’m {d} right now, back on it in a moment.', + 'Noted "{t}" — still {d}, then it’s next.', + 'Got "{t}" — I’ll get to it once I’m done {d}.', + ], + topic: [ + 'Saw "{t}" — I’ll pick it up right after this step.', + 'Noted "{t}", coming to it shortly.', + '"{t}" is next on my list — one moment.', + ], + doing: [ + 'Seen — I’m {d}, back to you shortly.', + 'New message noted; still {d}, one moment.', + 'On it as soon as I’m done {d}.', + ], + bare: [ + 'Seen — back to you right after this step.', + 'Noted, one moment.', + 'Picking this up as soon as the current step ends.', + ], + }, +} + +/** + * One short ack line for `message` while the turn is doing `tool`. `seed` picks the + * variant; `previous` is never returned again (the next variant is taken instead). + */ +export function composeBusyAck({ message, tool, uiLanguage, seed = 0, previous } = {}) { + const lang = ackLanguage(message, uiLanguage) + const topic = topicOf(message, lang) + const doing = doingOf(tool, lang) + const kind = topic && doing ? 'topicDoing' : topic ? 'topic' : doing ? 'doing' : 'bare' + const pool = TEMPLATES[lang][kind] + const fill = index => pool[index % pool.length].replaceAll('{t}', topic).replaceAll('{d}', doing ?? '') + const start = Math.abs(Math.trunc(seed)) % pool.length + for (let step = 0; step < pool.length; step += 1) { + const text = fill(start + step) + if (text !== previous) return text + } + return fill(start) +} export function shouldNoteBusyAck({ sourceKind, turnRunning, ownChat }) { return sourceKind === 'user' && turnRunning === true && ownChat === true } +const textOf = (content) => { + if (typeof content === 'string') return content + if (!Array.isArray(content)) return '' + return content.filter(block => block?.type === 'text' && typeof block.text === 'string').map(block => block.text).join('') +} + export function install(rt) { const { ctx } = rt /** @type {Map} */ const acks = new Map() + /** Last ack text per chat, kept across clears so consecutive acks differ. */ + const lastText = new Map() + let counter = 0 const turnRunning = sessionId => rt.live?.get(sessionId)?.turn !== undefined const ownChat = sessionId => rt.chatOwners?.has(sessionId) === true - const note = (sessionId) => { + const note = (sessionId, message) => { const now = Date.now() const existing = acks.get(sessionId) if (existing && now - existing.at < COALESCE_MS) { existing.at = now return existing } - const entry = { text: busyAckText(rt.uiLanguage?.()), at: now } + counter += 1 + const text = composeBusyAck({ + message, + tool: rt.live?.get(sessionId)?.tool, + uiLanguage: rt.uiLanguage?.(), + seed: counter + now, + previous: lastText.get(sessionId), + }) + lastText.set(sessionId, text) + const entry = { text, at: now } acks.set(sessionId, entry) return entry } const clear = (sessionId) => { acks.delete(sessionId) } - const textOfAssistant = (content) => { - if (!Array.isArray(content)) return '' - return content.filter(block => block?.type === 'text' && typeof block.text === 'string').map(block => block.text).join('') - } - ctx.on('session/event', (session, event) => { const id = session?.id if (id === undefined || event?.type === undefined) return @@ -48,11 +176,11 @@ export function install(rt) { turnRunning: turnRunning(id), ownChat: ownChat(id), })) return - note(id) + note(id, textOf(event.data?.content ?? event.data?.message?.content)) return } if (event.type === 'assistant/message') { - if (textOfAssistant(event.data?.message?.content).trim() !== '') clear(id) + if (textOf(event.data?.message?.content).trim() !== '') clear(id) return } if (event.type === 'turn/end') clear(id) 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 785ebaa70..33931a3a5 100644 --- a/patches/ds-bot/files/test/busy-ack-client.test.js +++ b/patches/ds-bot/files/test/busy-ack-client.test.js @@ -18,5 +18,5 @@ test('busyAckVisible prefers host or pending ack and hides once spoken text stre assert.equal(busyAckVisible({ hostAck: { text: '收到' }, pendingBusy: false, streamingText: false }), true) assert.equal(busyAckVisible({ hostAck: null, pendingBusy: true, streamingText: false }), true) assert.equal(busyAckVisible({ hostAck: { text: '收到' }, pendingBusy: true, streamingText: true }), false) - assert.equal(BUSY_ACK_KEY.includes('Got it'), true) + assert.equal(BUSY_ACK_KEY.includes('answer properly once I finish'), false) }) diff --git a/patches/ds-bot/files/test/busy-ack.test.js b/patches/ds-bot/files/test/busy-ack.test.js index 7e62242f3..cb3170fd0 100644 --- a/patches/ds-bot/files/test/busy-ack.test.js +++ b/patches/ds-bot/files/test/busy-ack.test.js @@ -1,13 +1,70 @@ import assert from 'node:assert/strict' import { test } from 'node:test' import { - BUSY_ACK_EN, BUSY_ACK_ZH, busyAckText, shouldNoteBusyAck, + ackLanguage, composeBusyAck, doingOf, shouldNoteBusyAck, topicOf, } from '../src/host/busy-ack.js' -test('busyAckText follows the UI language', () => { - assert.equal(busyAckText('English'), BUSY_ACK_EN) - assert.equal(busyAckText('Chinese (简体中文)'), BUSY_ACK_ZH) - assert.equal(busyAckText('zh-CN'), BUSY_ACK_ZH) +// The pre-#1629 canned lines; an ack must never be either verbatim. +const OLD_ZH = '收到,我先记着,手头忙完再详细回你。' +const OLD_EN = 'Got it — I’ll answer properly once I finish what’s in progress.' + +const harness = (live, uiLanguage = 'English') => { + const listeners = new Map() + const rt = { live, chatOwners: new Map([...live.keys()].map(id => [id, id])), uiLanguage: () => uiLanguage } + const ctx = { on(name, fn) { (listeners.get(name) ?? listeners.set(name, []).get(name)).push(fn) } } + return { rt: Object.assign(rt, { ctx }), emit: (name, ...args) => { for (const fn of listeners.get(name) ?? []) fn(...args) } } +} +const say = text => ({ type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text }] } }) + +test('the ack language follows the message, then the UI language', () => { + assert.equal(ackLanguage('帮我看下日志', 'English'), 'zh') + assert.equal(ackLanguage('check the logs please', 'Chinese (简体中文)'), 'en') + assert.equal(ackLanguage('👍', 'zh-CN'), 'zh') + assert.equal(ackLanguage('', 'English'), 'en') +}) + +test('topicOf clips the first line and drops filler', () => { + assert.equal(topicOf('帮我改周报\n第二行', 'zh'), '帮我改周报') + assert.equal(topicOf('在吗', 'zh'), '') + assert.equal(topicOf('hi', 'en'), '') + const long = topicOf('Can you also check the deploy logs for the staging cluster?', 'en') + assert.ok([...long].length <= 36 && long.endsWith('…'), long) +}) + +test('doingOf names the running tool in words', () => { + assert.equal(doingOf('bash', 'zh'), '跑命令') + assert.equal(doingOf('wait_job', 'en'), 'waiting on a job') + assert.equal(doingOf('ask_user', 'en'), undefined) + assert.equal(doingOf(undefined, 'en'), undefined) + assert.equal(doingOf('frobnicate', 'en'), 'using frobnicate') +}) + +test('composeBusyAck reflects the message and the tool, one line, never the canned sentence', () => { + const zh = composeBusyAck({ message: '帮我把周报的数据图再改一下', tool: 'bash' }) + assert.match(zh, /周报/) + assert.match(zh, /跑命令/) + const en = composeBusyAck({ message: 'Can you check the deploy logs?', tool: 'wait_job' }) + assert.match(en, /deploy logs/) + assert.match(en, /waiting on a job/) + for (const message of ['在吗', 'hello', '帮我查下天气', 'ship it', '']) { + for (const tool of [undefined, 'bash', 'web_fetch']) { + for (let seed = 0; seed < 6; seed += 1) { + const text = composeBusyAck({ message, tool, seed, uiLanguage: 'Chinese' }) + assert.notEqual(text, OLD_ZH) + assert.notEqual(text, OLD_EN) + assert.ok(!text.includes('\n') && [...text].length <= 90, text) + } + } + } +}) + +test('composeBusyAck never repeats the previous ack', () => { + let previous + for (let seed = 0; seed < 10; seed += 1) { + const text = composeBusyAck({ message: '在吗', seed: 0, previous }) + assert.notEqual(text, previous) + previous = text + } }) test('shouldNoteBusyAck only for a human line in an own chat while a turn runs', () => { @@ -18,85 +75,52 @@ test('shouldNoteBusyAck only for a human line in an own chat while a turn runs', assert.equal(shouldNoteBusyAck({ sourceKind: undefined, turnRunning: true, ownChat: true }), false) }) -test('install notes a human message while a turn runs and clears on spoken assistant text', async () => { +test('install composes the ack from the message and the live tool, coalesces, clears on spoken text', async () => { const { install } = await import('../src/host/busy-ack.js') - const listeners = new Map() - const live = new Map([['a', { turn: 1 }]]) - const chatOwners = new Map([['a', 'a']]) - const rt = { - live, - chatOwners, - uiLanguage: () => 'Chinese (简体中文)', - } - const ctx = { - on(name, fn) { - (listeners.get(name) ?? listeners.set(name, []).get(name)).push(fn) - }, - } - install(Object.assign(rt, { ctx })) + const live = new Map([['a', { turn: 1, tool: 'bash' }]]) + const { rt, emit } = harness(live, 'English') + install(rt) assert.deepEqual(rt.busyAcksView(), {}) - for (const fn of listeners.get('session/event')) { - fn({ id: 'a' }, { type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: 'hello' }] } }) - } + emit('session/event', { id: 'a' }, say('帮我把周报再改一下')) const acks = rt.busyAcksView() - assert.equal(acks.a.text, BUSY_ACK_ZH) + assert.match(acks.a.text, /周报/) + assert.match(acks.a.text, /跑命令/) + assert.notEqual(acks.a.text, OLD_ZH) assert.ok(typeof acks.a.at === 'number') - // A second burst within the coalesce window keeps one ack. const at = acks.a.at - for (const fn of listeners.get('session/event')) { - fn({ id: 'a' }, { type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: 'again' }] } }) - } + emit('session/event', { id: 'a' }, say('again')) assert.equal(Object.keys(rt.busyAcksView()).length, 1) + assert.equal(rt.busyAcksView().a.text, acks.a.text) assert.ok(rt.busyAcksView().a.at >= at) - // Idle agent with no turn does not note. live.delete('a') - for (const fn of listeners.get('session/event')) { - fn({ id: 'a' }, { type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: 'idle' }] } }) - } + emit('session/event', { id: 'a' }, say('idle')) assert.ok(rt.busyAcksView().a) - // Spoken assistant text clears the ack. live.set('a', { turn: 1 }) - for (const fn of listeners.get('session/event')) { - fn({ id: 'a' }, { type: 'assistant/message', data: { message: { content: [{ type: 'text', text: '详细回复' }] } } }) - } + emit('session/event', { id: 'a' }, { type: 'assistant/message', data: { message: { content: [{ type: 'text', text: '详细回复' }] } } }) assert.deepEqual(rt.busyAcksView(), {}) }) -test('install clears on turn/end and agent idle', async () => { - const { install, BUSY_ACK_EN } = await import('../src/host/busy-ack.js') - const listeners = new Map() +test('consecutive acks in one chat differ, and clear on turn/end and agent idle', async () => { + const { install } = await import('../src/host/busy-ack.js') const live = new Map([['a', { turn: 2 }]]) - const rt = { - live, - chatOwners: new Map([['a', 'a']]), - uiLanguage: () => 'English', - } - const ctx = { - on(name, fn) { - (listeners.get(name) ?? listeners.set(name, []).get(name)).push(fn) - }, - } - install(Object.assign(rt, { ctx })) - for (const fn of listeners.get('session/event')) { - fn({ id: 'a' }, { type: 'user/message', data: { source: { kind: 'user' } } }) - } - assert.equal(rt.busyAcksView().a.text, BUSY_ACK_EN) - - for (const fn of listeners.get('session/event')) { - fn({ id: 'a' }, { type: 'turn/end', data: { turn: 2 } }) - } - assert.deepEqual(rt.busyAcksView(), {}) - - for (const fn of listeners.get('session/event')) { - fn({ id: 'a' }, { type: 'user/message', data: { source: { kind: 'user' } } }) + const { rt, emit } = harness(live, 'English') + install(rt) + const seen = [] + for (let round = 0; round < 4; round += 1) { + emit('session/event', { id: 'a' }, { type: 'user/message', data: { source: { kind: 'user' } } }) + const text = rt.busyAcksView().a.text + assert.notEqual(text, OLD_EN) + if (seen.length) assert.notEqual(text, seen.at(-1)) + seen.push(text) + emit('session/event', { id: 'a' }, { type: 'turn/end', data: { turn: 2 } }) + assert.deepEqual(rt.busyAcksView(), {}) } + emit('session/event', { id: 'a' }, say('one more')) assert.ok(rt.busyAcksView().a) - for (const fn of listeners.get('agent/status')) { - fn({ agent: { session: { id: 'a' } }, status: 'idle' }) - } + emit('agent/status', { agent: { session: { id: 'a' } }, status: 'idle' }) assert.deepEqual(rt.busyAcksView(), {}) }) From 69a9b2ba1d9cd52593321916a6f05892f19b84c8 Mon Sep 17 00:00:00 2001 From: Birfy Date: Thu, 29 Oct 2026 11:21:48 +0800 Subject: [PATCH 2/2] docs(core-client): busy-ack composed text (#1629) --- docs/subsystems/core-client.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index f436d8f44..cc4379f41 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -120,7 +120,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second ### Busy-send spoken acknowledgment (#1584) -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`). 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`. +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 client shows the `BUSY_ACK_KEY` fallback line. 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`. ### Face-room held bubble and overview