diff --git a/changelog.d/1665-busy-ack-model.changed.md b/changelog.d/1665-busy-ack-model.changed.md new file mode 100644 index 000000000..09d384dcb --- /dev/null +++ b/changelog.d/1665-busy-ack-model.changed.md @@ -0,0 +1 @@ +A Bot that is busy now acknowledges a new message with one short, natural sentence written by a quick model call, in the message's language, instead of a templated line that quoted a truncated piece of the message; when the model does not answer within 3 seconds only the pending dots show ([#1665](https://github.com/ThinkFlowLab/FlyBot/issues/1665)). diff --git a/core/src/host/busy-ack.js b/core/src/host/busy-ack.js index f15e603a4..fb1e743db 100644 --- a/core/src/host/busy-ack.js +++ b/core/src/host/busy-ack.js @@ -1,128 +1,50 @@ // Busy-ack (#1584): a human message that lands while a Bot turn is already running -// 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. +// gets a short spoken acknowledgment on the live payload. 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. // -// 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. +// The line is written by one short model call (#1665), not a template: the templated +// lines of #1629 quoted a clipped, truncated piece of the message back inside a fixed +// phrase and read as canned. The ack is published at once with an empty text, which the +// client draws as the avatar with blinking dots (#1656/#1657); the model's sentence fills +// it in when it comes. The call is separate from the busy turn, uses the reasoning-off +// setting when the model has one, a tiny output cap, and a hard ACK_TIMEOUT_MS; on a +// timeout, an error or an empty answer no text is ever sent and the dots clear with the +// turn as before. `config.flybot.modelFor['busy-ack']` names a cheaper model for it; +// otherwise the chat's own model is used. +import { BlockAssembler } from '@deepseek-ai/dsh-llm' +import { splitRef } from './text.js' + export const COALESCE_MS = 3_000 -export const TOPIC_MAX = { zh: 16, en: 36 } - -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' +export const ACK_TIMEOUT_MS = 3_000 +export const ACK_MAX_TOKENS = 80 +export const ACK_MAX_CHARS = 120 + +export const ACK_SYSTEM = [ + 'You write the one-line acknowledgment a busy assistant sends when a new message arrives while it is still working.', + 'Reply in the same language as the new message.', + 'Write one natural, short sentence that shows you understood what the new message is about and says briefly what you are doing right now.', + 'Do not quote or copy the message, do not use quotation marks, and do not use stock phrases such as "got it" or "noted"; vary your wording.', + 'Output only that sentence.', +].join(' ') + +/** The user prompt for the ack call: the new message and what the turn is doing. */ +export function ackPrompt({ message, tool, working }) { + const doing = typeof tool === 'string' && tool.trim() !== '' && tool !== 'ask_user' ? `running the tool \`${tool}\`` : 'working on the previous request' + const task = String(working ?? '').trim().slice(0, 600) + return [ + `Currently: ${doing}.`, + ...(task === '' ? [] : [`The request being worked on: ${task}`]), + `New message: ${String(message ?? '').trim().slice(0, 1_500)}`, + ].join('\n') } -/** A short topic out of the message: first line, trimmed, clipped; '' when too thin to echo. */ -export function topicOf(text, lang) { +/** The model's answer as one clean line, or '' when unusable. */ +export function cleanAck(text) { 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] - // One pass only: the topic is the person's own text, so a `{t}`/`{d}` they typed - // must not be rescanned as template markup after it is inserted (#1634). - const fill = index => pool[index % pool.length].replace(/\{(t|d)\}/g, (_, key) => (key === 't' ? topic : (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) + const clean = line.replace(/^["'“”‘’「」『』]+|["'“”‘’「」『』]+$/gu, '').trim() + if (clean === '' || [...clean].length > ACK_MAX_CHARS) return '' + return clean } export function shouldNoteBusyAck({ sourceKind, turnRunning, ownChat }) { @@ -139,7 +61,7 @@ export function isHumanRoomLine(source) { && source.remote === undefined } -// The relay prefix delivery.js puts on the line; the ack quotes the person's text, not it. +// The relay prefix delivery.js puts on the line; the ack reads the person's text, not it. const relayHeader = /^\[[^\]\n]*\]\r?\n/ const textOf = (content) => { @@ -149,11 +71,9 @@ const textOf = (content) => { } export function install(rt) { - const { ctx } = rt - /** @type {Map} */ + const { ctx, config } = 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 @@ -162,7 +82,59 @@ export function install(rt) { /** 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) => { + // The route for the ack: a configured economy model first, else the chat's own. + const routeOf = (session) => { + const named = config?.flybot?.modelFor?.['busy-ack'] + const own = typeof named === 'string' && named.trim() !== '' ? splitRef(named.trim()) : undefined + if (own?.provider && own?.model) return own + const header = session?.requestHeader?.()?.config + return header?.provider && header?.model ? { provider: header.provider, model: header.model } : undefined + } + + // The latest earlier user request in the Session: what the busy turn is about. + const workingOf = (session, message) => { + try { + const messages = session?.deriveMessages?.() ?? [] + for (let index = messages.length - 1; index >= 0; index -= 1) { + if (messages[index]?.role !== 'user') continue + const text = textOf(messages[index].content).replace(relayHeader, '').trim() + if (text !== '' && text !== String(message ?? '').trim()) return text + } + } catch { /* no history is fine */ } + return '' + } + + // One short call; resolves to the line or '' — never throws, never outlives the timeout. + async function write(session, sessionId, message) { + const route = routeOf(session) + if (route === undefined || typeof ctx.llm?.stream !== 'function') return '' + const signal = AbortSignal.timeout(ACK_TIMEOUT_MS) + const work = (async () => { + const info = await ctx.llm.resolveModelInfo?.(route.provider, route.model)?.catch?.(() => undefined) + const off = info?.reasoning?.efforts?.some(effort => effort.id === 'off') + const messages = [{ role: 'user', content: [{ type: 'text', text: ackPrompt({ message, tool: rt.live?.get(sessionId)?.tool, working: workingOf(session, message) }) }] }] + rt.tagUsage?.(messages, { sessionId, kind: 'other' }) + const assembler = new BlockAssembler() + for await (const chunk of ctx.llm.stream({ + provider: route.provider, + model: route.model, + ...(off ? { reasoningEffort: 'off' } : {}), + system: ACK_SYSTEM, + messages, + maxTokens: ACK_MAX_TOKENS, + purpose: 'other', + signal, + })) assembler.push(chunk) + const finish = assembler.finish + if (finish?.kind === 'error' || finish?.kind === 'aborted' || finish?.kind === 'max-tokens') return '' + return cleanAck(assembler.blocks().filter(block => block.type === 'text').map(block => block.text).join('\n')) + })().catch(() => '') + let timer + const late = new Promise(resolve => { timer = setTimeout(() => resolve(''), ACK_TIMEOUT_MS) }) + try { return await Promise.race([work, late]) } finally { clearTimeout(timer) } + } + + const note = (sessionId, message, session) => { const now = Date.now() const existing = acks.get(sessionId) if (existing && now - existing.at < COALESCE_MS) { @@ -170,16 +142,13 @@ export function install(rt) { return existing } 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 } + // Published at once with no text: the client draws the pending dots (#1657). + const entry = { text: '', at: now, id: counter } acks.set(sessionId, entry) + entry.ready = write(session, sessionId, message).then((text) => { + if (text !== '' && acks.get(sessionId) === entry) entry.text = text + return entry.text + }) return entry } @@ -194,7 +163,7 @@ export function install(rt) { 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) + note(id, room ? text.replace(relayHeader, '') : text, session) return } if (event.type === 'assistant/message') { @@ -222,6 +191,8 @@ export function install(rt) { return [[botId, ack === undefined ? { text: '', at: 0 } : { text: ack.text, at: ack.at }]] })), noteBusyAck: note, + // The pending model answer for a Session's ack ('' when none); for tests and diagnostics. + busyAckReady: sessionId => acks.get(sessionId)?.ready ?? Promise.resolve(''), clearBusyAck: clear, }) } diff --git a/core/test/busy-ack.test.js b/core/test/busy-ack.test.js index 677a1f1f6..2d12614b4 100644 --- a/core/test/busy-ack.test.js +++ b/core/test/busy-ack.test.js @@ -1,77 +1,46 @@ import assert from 'node:assert/strict' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' import { - ackLanguage, composeBusyAck, doingOf, install as installBusyAck, shouldNoteBusyAck, topicOf, + ACK_SYSTEM, ACK_TIMEOUT_MS, ackPrompt, cleanAck, install as installBusyAck, shouldNoteBusyAck, } 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 = '收到,我先记着,手头忙完再详细回你。' -const OLD_EN = 'Got it — I’ll answer properly once I finish what’s in progress.' +// A mocked model (#1665): records every call and answers with `reply(prompt)`, after +// `delay` ms; `fail` makes the stream end in an error, `hang` never ends until aborted. +const mockLlm = ({ reply = () => '在跑命令,周报的图我改完就接着看。', delay = 0, fail = false, hang = false } = {}) => { + const calls = [] + return { + calls, + async resolveModelInfo() { return { reasoning: { efforts: [{ id: 'off' }, { id: 'high' }] } } }, + async* stream(options) { + calls.push(options) + if (hang) await new Promise((resolve, reject) => options.signal.addEventListener('abort', () => reject(options.signal.reason))) + if (delay) await new Promise(resolve => setTimeout(resolve, delay)) + if (fail) { yield { type: 'finish', reason: { kind: 'error', failure: { message: 'boom' } } }; return } + yield { type: 'text-delta', index: 0, text: reply(options.messages[0].content[0].text) } + yield { type: 'finish', reason: { kind: 'stop' } } + }, + } +} +const session = id => ({ id, requestHeader: () => ({ config: { provider: 'p', model: 'big' } }), deriveMessages: () => [] }) -const harness = (live, uiLanguage = 'English') => { +const harness = (live, llm = mockLlm(), config) => { 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 rt = { live, config, chatOwners: new Map([...live.keys()].map(id => [id, id])) } + const ctx = { llm, on(name, fn) { (listeners.get(name) ?? listeners.set(name, []).get(name)).push(fn) } } + return { rt: Object.assign(rt, { ctx }), llm, 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('the template generator is gone: no quoted echo, no truncation helpers', () => { + const source = readFileSync(new URL('../src/host/busy-ack.js', import.meta.url), 'utf8') + for (const gone of ['TEMPLATES', 'topicOf', 'TOPIC_MAX', 'composeBusyAck', '记下了']) assert.ok(!source.includes(gone), gone) }) -test('shouldNoteBusyAck only for a human line in an own chat while a turn runs', () => { +test('shouldNoteBusyAck only for user messages during an own-chat running turn', () => { assert.equal(shouldNoteBusyAck({ sourceKind: 'user', turnRunning: true, ownChat: true }), true) assert.equal(shouldNoteBusyAck({ sourceKind: 'user', turnRunning: false, ownChat: true }), false) assert.equal(shouldNoteBusyAck({ sourceKind: 'user', turnRunning: true, ownChat: false }), false) @@ -79,82 +48,97 @@ 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 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') +test('the prompt asks for the user\'s language, the real content, no quoting, no stock phrases', () => { + assert.match(ACK_SYSTEM, /same language/) + assert.match(ACK_SYSTEM, /Do not quote/) + assert.match(ACK_SYSTEM, /stock phrases/) + const prompt = ackPrompt({ message: '那些 needs-human 的 issue 帮我过一遍', tool: 'bash', working: '修 CI' }) + assert.match(prompt, /needs-human/) + assert.match(prompt, /`bash`/) + assert.match(prompt, /修 CI/) + assert.doesNotMatch(ackPrompt({ message: 'x', tool: 'ask_user' }), /ask_user/) +}) + +test('cleanAck keeps one line, strips wrapping quotes, drops empty or overlong answers', () => { + assert.equal(cleanAck('「好,我先把 CI 修完再看那些 issue。」\nextra'), '好,我先把 CI 修完再看那些 issue。') + assert.equal(cleanAck(' '), '') + assert.equal(cleanAck('x'.repeat(500)), '') +}) + +test('the ack is published pending at once, then filled by one short model call', async () => { const live = new Map([['a', { turn: 1, tool: 'bash' }]]) - const { rt, emit } = harness(live, 'English') - install(rt) + const { rt, llm, emit } = harness(live) + installBusyAck(rt) assert.deepEqual(rt.busyAcksView(), {}) + emit('session/event', session('a'), say('帮我把周报的数据图再改一下')) + assert.equal(rt.busyAcksView().a.text, '', 'pending: the client draws dots') + assert.equal(await settleAck(rt, 'a'), '在跑命令,周报的图我改完就接着看。') + assert.equal(rt.busyAcksView().a.text, '在跑命令,周报的图我改完就接着看。') + assert.equal(llm.calls.length, 1) + const call = llm.calls[0] + assert.equal(call.model, 'big') + assert.equal(call.reasoningEffort, 'off') + assert.equal(call.system, ACK_SYSTEM) + assert.ok(call.maxTokens <= 100) + assert.match(call.messages[0].content[0].text, /周报的数据图/) + assert.match(call.messages[0].content[0].text, /`bash`/) - emit('session/event', { id: 'a' }, say('帮我把周报再改一下')) - const acks = rt.busyAcksView() - assert.match(acks.a.text, /周报/) - assert.match(acks.a.text, /跑命令/) - assert.notEqual(acks.a.text, OLD_ZH) - assert.ok(typeof acks.a.at === 'number') - - const at = acks.a.at - emit('session/event', { id: 'a' }, say('again')) - assert.equal(Object.keys(rt.busyAcksView()).length, 1) - assert.equal(rt.busyAcksView().a.text, acks.a.text) + // Coalesced: a second line inside the window keeps the one ack and makes no call. + const at = rt.busyAcksView().a.at + emit('session/event', session('a'), say('again')) + assert.equal(llm.calls.length, 1) assert.ok(rt.busyAcksView().a.at >= at) - live.delete('a') - emit('session/event', { id: 'a' }, say('idle')) - assert.ok(rt.busyAcksView().a) - - live.set('a', { turn: 1 }) - emit('session/event', { id: 'a' }, { type: 'assistant/message', data: { message: { content: [{ type: 'text', text: '详细回复' }] } } }) + emit('session/event', session('a'), { type: 'assistant/message', data: { message: { content: [{ type: 'text', text: '详细回复' }] } } }) assert.deepEqual(rt.busyAcksView(), {}) }) -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, 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 } }) +test('a configured economy model is used for the ack', async () => { + const live = new Map([['a', { turn: 1 }]]) + const { rt, llm, emit } = harness(live, mockLlm(), { flybot: { modelFor: { 'busy-ack': 'p/flash' } } }) + installBusyAck(rt) + emit('session/event', session('a'), say('check the deploy logs')) + await settleAck(rt, 'a') + assert.equal(llm.calls[0].provider, 'p') + assert.equal(llm.calls[0].model, 'flash') +}) + +test('an error, a timeout or a too-slow model sends no text; the pending ack clears with the turn', async () => { + for (const llm of [mockLlm({ fail: true }), mockLlm({ hang: true }), mockLlm({ delay: ACK_TIMEOUT_MS + 500 })]) { + const live = new Map([['a', { turn: 1 }]]) + const { rt, emit } = harness(live, llm) + installBusyAck(rt) + const started = Date.now() + emit('session/event', session('a'), say('还在吗,顺便看下日志')) + assert.equal(await settleAck(rt, 'a'), '') + assert.ok(Date.now() - started < ACK_TIMEOUT_MS + 400, 'bounded by the hard timeout') + assert.equal(rt.busyAcksView().a.text, '') + emit('session/event', session('a'), { type: 'turn/end', data: { turn: 1 } }) assert.deepEqual(rt.busyAcksView(), {}) } - emit('session/event', { id: 'a' }, say('one more')) - assert.ok(rt.busyAcksView().a) +}) + +test('a late answer never revives a cleared ack', async () => { + const live = new Map([['a', { turn: 2 }]]) + const { rt, emit } = harness(live, mockLlm({ delay: 50 })) + installBusyAck(rt) + emit('session/event', session('a'), say('one more')) + const ready = rt.busyAckReady('a') emit('agent/status', { agent: { session: { id: 'a' } }, status: 'idle' }) + await ready assert.deepEqual(rt.busyAcksView(), {}) }) -// The quoted topic is the person's own text: a `{d}` / `{t}` they typed is not template -// markup and must be echoed unchanged. Messages stay short on purpose — topicOf() clips -// at TOPIC_MAX, and a long sentence would hide the rewrite behind the ellipsis. -test('a {d} typed in the message is quoted back verbatim, with and without a running tool', () => { - for (const message of ['改 {d} 这行', 'fix "{d}" here']) { - for (const tool of ['bash', undefined]) { - for (let seed = 0; seed < 6; seed += 1) { - const text = composeBusyAck({ message, tool, seed, uiLanguage: 'zh-CN' }) - assert.ok(text.includes('{d}'), `${message} / ${tool ?? 'no tool'} / seed ${seed}: ${text}`) - assert.ok(text.includes(topicOf(message, ackLanguage(message, 'zh-CN'))), `${message} / seed ${seed}: ${text}`) - } - } - } +test('no model route means no call and no text', async () => { + const live = new Map([['a', { turn: 1 }]]) + const { rt, llm, emit } = harness(live) + installBusyAck(rt) + emit('session/event', { id: 'a' }, say('hello there')) + assert.equal(await settleAck(rt, 'a'), '') + assert.equal(llm.calls.length, 0) }) -test('a {t} typed in the message is quoted back verbatim, with and without a running tool', () => { - for (const message of ['改 {t} 这行', 'fix "{t}" here']) { - for (const tool of ['bash', undefined]) { - for (let seed = 0; seed < 6; seed += 1) { - const text = composeBusyAck({ message, tool, seed, uiLanguage: 'zh-CN' }) - assert.ok(text.includes('{t}'), `${message} / ${tool ?? 'no tool'} / seed ${seed}: ${text}`) - assert.ok(text.includes(topicOf(message, ackLanguage(message, 'zh-CN'))), `${message} / seed ${seed}: ${text}`) - } - } - } -}) +const settleAck = async (rt, id) => (await rt.busyAckReady(id)) ?? '' // --- The room side (#1646) ------------------------------------------------- // A group's member speaks from a hidden Session (`room.sessions[botId]`), and the person's line @@ -185,6 +169,7 @@ const roomHost = async (t) => { const ctx = { logger: { warn() {}, info() {} }, effect(execute) { const dispose = execute(); if (typeof dispose === 'function') disposers.push(dispose) }, + llm: mockLlm({ reply: prompt => `ACK:${prompt.split('New message: ')[1]}` }), on(name, fn) { const list = listeners.get(name) ?? listeners.set(name, []).get(name); list.push(fn); return () => {} }, } const rt = { @@ -200,8 +185,8 @@ const roomHost = async (t) => { 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 } + const emit = (id, type, data) => fire('session/event', session(id), { type, seq: 1, data }) + return { rt, llm: ctx.llm, 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). @@ -227,14 +212,14 @@ test('a room line steered into a running member\'s Session notes one ack for tha 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/) + assert.equal(acks.gb.text, '') + assert.equal(await settleAck(host.rt, 'gb'), 'ACK:帮我把周报再改一下') + assert.doesNotMatch(host.llm.calls[0].messages[0].content[0].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.equal(host.rt.busyAcksView().gb.text, 'ACK:帮我把周报再改一下') assert.ok(host.rt.busyAcksView().gb.at >= at) // No ack survives the turn. host.emit('gb', 'turn/end', { turn: 1, reason: { kind: 'completed' } }) @@ -267,8 +252,8 @@ test('the room view names a running member before its line lands, then carries t // 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/) + await settleAck(host.rt, 'gb') + assert.equal(host.rt.roomBusyAcksView({ b: 'gb' }).b.text, 'ACK:帮我把周报再改一下') // No running turn, no entry; an empty member map stays empty. host.rt.live.delete('gb') assert.deepEqual(host.rt.roomBusyAcksView({ b: 'gb' }), {}) @@ -282,7 +267,7 @@ test('the own-chat ack is unchanged beside the room path (#1646)', async (t) => 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/) + assert.equal(await settleAck(host.rt, 'b'), 'ACK: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 }) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 1efb025e0..6693f6b65 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`); 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`. +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 written by one short model call on the host ([#1665](https://github.com/ThinkFlowLab/FlyBot/issues/1665)), replacing the templates of [#1629](https://github.com/ThinkFlowLab/FlyBot/issues/1629) that quoted a clipped piece of the message inside a fixed phrase: the ack is published at once with an empty text (the client draws the avatar with blinking dots), then a separate call — `config.flybot.modelFor['busy-ack']` when set, else the chat's own model, reasoning off when the model offers it, `ACK_MAX_TOKENS` output — is asked for one natural sentence in the message's language that shows what the new message is about and what the turn is doing (its running tool and the request it works on), with no quoting and no stock phrases. A hard `ACK_TIMEOUT_MS` (3 s) bounds it; a timeout, an error, an empty or overlong answer, or no model route leaves the text empty, so no sentence is ever sent and the dots clear with the turn. A late answer never revives a cleared 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/files/src/host/busy-ack.js b/patches/ds-bot/files/src/host/busy-ack.js index f15e603a4..fb1e743db 100644 --- a/patches/ds-bot/files/src/host/busy-ack.js +++ b/patches/ds-bot/files/src/host/busy-ack.js @@ -1,128 +1,50 @@ // Busy-ack (#1584): a human message that lands while a Bot turn is already running -// 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. +// gets a short spoken acknowledgment on the live payload. 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. // -// 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. +// The line is written by one short model call (#1665), not a template: the templated +// lines of #1629 quoted a clipped, truncated piece of the message back inside a fixed +// phrase and read as canned. The ack is published at once with an empty text, which the +// client draws as the avatar with blinking dots (#1656/#1657); the model's sentence fills +// it in when it comes. The call is separate from the busy turn, uses the reasoning-off +// setting when the model has one, a tiny output cap, and a hard ACK_TIMEOUT_MS; on a +// timeout, an error or an empty answer no text is ever sent and the dots clear with the +// turn as before. `config.flybot.modelFor['busy-ack']` names a cheaper model for it; +// otherwise the chat's own model is used. +import { BlockAssembler } from '@deepseek-ai/dsh-llm' +import { splitRef } from './text.js' + export const COALESCE_MS = 3_000 -export const TOPIC_MAX = { zh: 16, en: 36 } - -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' +export const ACK_TIMEOUT_MS = 3_000 +export const ACK_MAX_TOKENS = 80 +export const ACK_MAX_CHARS = 120 + +export const ACK_SYSTEM = [ + 'You write the one-line acknowledgment a busy assistant sends when a new message arrives while it is still working.', + 'Reply in the same language as the new message.', + 'Write one natural, short sentence that shows you understood what the new message is about and says briefly what you are doing right now.', + 'Do not quote or copy the message, do not use quotation marks, and do not use stock phrases such as "got it" or "noted"; vary your wording.', + 'Output only that sentence.', +].join(' ') + +/** The user prompt for the ack call: the new message and what the turn is doing. */ +export function ackPrompt({ message, tool, working }) { + const doing = typeof tool === 'string' && tool.trim() !== '' && tool !== 'ask_user' ? `running the tool \`${tool}\`` : 'working on the previous request' + const task = String(working ?? '').trim().slice(0, 600) + return [ + `Currently: ${doing}.`, + ...(task === '' ? [] : [`The request being worked on: ${task}`]), + `New message: ${String(message ?? '').trim().slice(0, 1_500)}`, + ].join('\n') } -/** A short topic out of the message: first line, trimmed, clipped; '' when too thin to echo. */ -export function topicOf(text, lang) { +/** The model's answer as one clean line, or '' when unusable. */ +export function cleanAck(text) { 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] - // One pass only: the topic is the person's own text, so a `{t}`/`{d}` they typed - // must not be rescanned as template markup after it is inserted (#1634). - const fill = index => pool[index % pool.length].replace(/\{(t|d)\}/g, (_, key) => (key === 't' ? topic : (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) + const clean = line.replace(/^["'“”‘’「」『』]+|["'“”‘’「」『』]+$/gu, '').trim() + if (clean === '' || [...clean].length > ACK_MAX_CHARS) return '' + return clean } export function shouldNoteBusyAck({ sourceKind, turnRunning, ownChat }) { @@ -139,7 +61,7 @@ export function isHumanRoomLine(source) { && source.remote === undefined } -// The relay prefix delivery.js puts on the line; the ack quotes the person's text, not it. +// The relay prefix delivery.js puts on the line; the ack reads the person's text, not it. const relayHeader = /^\[[^\]\n]*\]\r?\n/ const textOf = (content) => { @@ -149,11 +71,9 @@ const textOf = (content) => { } export function install(rt) { - const { ctx } = rt - /** @type {Map} */ + const { ctx, config } = 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 @@ -162,7 +82,59 @@ export function install(rt) { /** 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) => { + // The route for the ack: a configured economy model first, else the chat's own. + const routeOf = (session) => { + const named = config?.flybot?.modelFor?.['busy-ack'] + const own = typeof named === 'string' && named.trim() !== '' ? splitRef(named.trim()) : undefined + if (own?.provider && own?.model) return own + const header = session?.requestHeader?.()?.config + return header?.provider && header?.model ? { provider: header.provider, model: header.model } : undefined + } + + // The latest earlier user request in the Session: what the busy turn is about. + const workingOf = (session, message) => { + try { + const messages = session?.deriveMessages?.() ?? [] + for (let index = messages.length - 1; index >= 0; index -= 1) { + if (messages[index]?.role !== 'user') continue + const text = textOf(messages[index].content).replace(relayHeader, '').trim() + if (text !== '' && text !== String(message ?? '').trim()) return text + } + } catch { /* no history is fine */ } + return '' + } + + // One short call; resolves to the line or '' — never throws, never outlives the timeout. + async function write(session, sessionId, message) { + const route = routeOf(session) + if (route === undefined || typeof ctx.llm?.stream !== 'function') return '' + const signal = AbortSignal.timeout(ACK_TIMEOUT_MS) + const work = (async () => { + const info = await ctx.llm.resolveModelInfo?.(route.provider, route.model)?.catch?.(() => undefined) + const off = info?.reasoning?.efforts?.some(effort => effort.id === 'off') + const messages = [{ role: 'user', content: [{ type: 'text', text: ackPrompt({ message, tool: rt.live?.get(sessionId)?.tool, working: workingOf(session, message) }) }] }] + rt.tagUsage?.(messages, { sessionId, kind: 'other' }) + const assembler = new BlockAssembler() + for await (const chunk of ctx.llm.stream({ + provider: route.provider, + model: route.model, + ...(off ? { reasoningEffort: 'off' } : {}), + system: ACK_SYSTEM, + messages, + maxTokens: ACK_MAX_TOKENS, + purpose: 'other', + signal, + })) assembler.push(chunk) + const finish = assembler.finish + if (finish?.kind === 'error' || finish?.kind === 'aborted' || finish?.kind === 'max-tokens') return '' + return cleanAck(assembler.blocks().filter(block => block.type === 'text').map(block => block.text).join('\n')) + })().catch(() => '') + let timer + const late = new Promise(resolve => { timer = setTimeout(() => resolve(''), ACK_TIMEOUT_MS) }) + try { return await Promise.race([work, late]) } finally { clearTimeout(timer) } + } + + const note = (sessionId, message, session) => { const now = Date.now() const existing = acks.get(sessionId) if (existing && now - existing.at < COALESCE_MS) { @@ -170,16 +142,13 @@ export function install(rt) { return existing } 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 } + // Published at once with no text: the client draws the pending dots (#1657). + const entry = { text: '', at: now, id: counter } acks.set(sessionId, entry) + entry.ready = write(session, sessionId, message).then((text) => { + if (text !== '' && acks.get(sessionId) === entry) entry.text = text + return entry.text + }) return entry } @@ -194,7 +163,7 @@ export function install(rt) { 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) + note(id, room ? text.replace(relayHeader, '') : text, session) return } if (event.type === 'assistant/message') { @@ -222,6 +191,8 @@ export function install(rt) { return [[botId, ack === undefined ? { text: '', at: 0 } : { text: ack.text, at: ack.at }]] })), noteBusyAck: note, + // The pending model answer for a Session's ack ('' when none); for tests and diagnostics. + busyAckReady: sessionId => acks.get(sessionId)?.ready ?? Promise.resolve(''), clearBusyAck: clear, }) } diff --git a/patches/ds-bot/files/test/busy-ack.test.js b/patches/ds-bot/files/test/busy-ack.test.js index 677a1f1f6..2d12614b4 100644 --- a/patches/ds-bot/files/test/busy-ack.test.js +++ b/patches/ds-bot/files/test/busy-ack.test.js @@ -1,77 +1,46 @@ import assert from 'node:assert/strict' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' import { - ackLanguage, composeBusyAck, doingOf, install as installBusyAck, shouldNoteBusyAck, topicOf, + ACK_SYSTEM, ACK_TIMEOUT_MS, ackPrompt, cleanAck, install as installBusyAck, shouldNoteBusyAck, } 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 = '收到,我先记着,手头忙完再详细回你。' -const OLD_EN = 'Got it — I’ll answer properly once I finish what’s in progress.' +// A mocked model (#1665): records every call and answers with `reply(prompt)`, after +// `delay` ms; `fail` makes the stream end in an error, `hang` never ends until aborted. +const mockLlm = ({ reply = () => '在跑命令,周报的图我改完就接着看。', delay = 0, fail = false, hang = false } = {}) => { + const calls = [] + return { + calls, + async resolveModelInfo() { return { reasoning: { efforts: [{ id: 'off' }, { id: 'high' }] } } }, + async* stream(options) { + calls.push(options) + if (hang) await new Promise((resolve, reject) => options.signal.addEventListener('abort', () => reject(options.signal.reason))) + if (delay) await new Promise(resolve => setTimeout(resolve, delay)) + if (fail) { yield { type: 'finish', reason: { kind: 'error', failure: { message: 'boom' } } }; return } + yield { type: 'text-delta', index: 0, text: reply(options.messages[0].content[0].text) } + yield { type: 'finish', reason: { kind: 'stop' } } + }, + } +} +const session = id => ({ id, requestHeader: () => ({ config: { provider: 'p', model: 'big' } }), deriveMessages: () => [] }) -const harness = (live, uiLanguage = 'English') => { +const harness = (live, llm = mockLlm(), config) => { 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 rt = { live, config, chatOwners: new Map([...live.keys()].map(id => [id, id])) } + const ctx = { llm, on(name, fn) { (listeners.get(name) ?? listeners.set(name, []).get(name)).push(fn) } } + return { rt: Object.assign(rt, { ctx }), llm, 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('the template generator is gone: no quoted echo, no truncation helpers', () => { + const source = readFileSync(new URL('../src/host/busy-ack.js', import.meta.url), 'utf8') + for (const gone of ['TEMPLATES', 'topicOf', 'TOPIC_MAX', 'composeBusyAck', '记下了']) assert.ok(!source.includes(gone), gone) }) -test('shouldNoteBusyAck only for a human line in an own chat while a turn runs', () => { +test('shouldNoteBusyAck only for user messages during an own-chat running turn', () => { assert.equal(shouldNoteBusyAck({ sourceKind: 'user', turnRunning: true, ownChat: true }), true) assert.equal(shouldNoteBusyAck({ sourceKind: 'user', turnRunning: false, ownChat: true }), false) assert.equal(shouldNoteBusyAck({ sourceKind: 'user', turnRunning: true, ownChat: false }), false) @@ -79,82 +48,97 @@ 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 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') +test('the prompt asks for the user\'s language, the real content, no quoting, no stock phrases', () => { + assert.match(ACK_SYSTEM, /same language/) + assert.match(ACK_SYSTEM, /Do not quote/) + assert.match(ACK_SYSTEM, /stock phrases/) + const prompt = ackPrompt({ message: '那些 needs-human 的 issue 帮我过一遍', tool: 'bash', working: '修 CI' }) + assert.match(prompt, /needs-human/) + assert.match(prompt, /`bash`/) + assert.match(prompt, /修 CI/) + assert.doesNotMatch(ackPrompt({ message: 'x', tool: 'ask_user' }), /ask_user/) +}) + +test('cleanAck keeps one line, strips wrapping quotes, drops empty or overlong answers', () => { + assert.equal(cleanAck('「好,我先把 CI 修完再看那些 issue。」\nextra'), '好,我先把 CI 修完再看那些 issue。') + assert.equal(cleanAck(' '), '') + assert.equal(cleanAck('x'.repeat(500)), '') +}) + +test('the ack is published pending at once, then filled by one short model call', async () => { const live = new Map([['a', { turn: 1, tool: 'bash' }]]) - const { rt, emit } = harness(live, 'English') - install(rt) + const { rt, llm, emit } = harness(live) + installBusyAck(rt) assert.deepEqual(rt.busyAcksView(), {}) + emit('session/event', session('a'), say('帮我把周报的数据图再改一下')) + assert.equal(rt.busyAcksView().a.text, '', 'pending: the client draws dots') + assert.equal(await settleAck(rt, 'a'), '在跑命令,周报的图我改完就接着看。') + assert.equal(rt.busyAcksView().a.text, '在跑命令,周报的图我改完就接着看。') + assert.equal(llm.calls.length, 1) + const call = llm.calls[0] + assert.equal(call.model, 'big') + assert.equal(call.reasoningEffort, 'off') + assert.equal(call.system, ACK_SYSTEM) + assert.ok(call.maxTokens <= 100) + assert.match(call.messages[0].content[0].text, /周报的数据图/) + assert.match(call.messages[0].content[0].text, /`bash`/) - emit('session/event', { id: 'a' }, say('帮我把周报再改一下')) - const acks = rt.busyAcksView() - assert.match(acks.a.text, /周报/) - assert.match(acks.a.text, /跑命令/) - assert.notEqual(acks.a.text, OLD_ZH) - assert.ok(typeof acks.a.at === 'number') - - const at = acks.a.at - emit('session/event', { id: 'a' }, say('again')) - assert.equal(Object.keys(rt.busyAcksView()).length, 1) - assert.equal(rt.busyAcksView().a.text, acks.a.text) + // Coalesced: a second line inside the window keeps the one ack and makes no call. + const at = rt.busyAcksView().a.at + emit('session/event', session('a'), say('again')) + assert.equal(llm.calls.length, 1) assert.ok(rt.busyAcksView().a.at >= at) - live.delete('a') - emit('session/event', { id: 'a' }, say('idle')) - assert.ok(rt.busyAcksView().a) - - live.set('a', { turn: 1 }) - emit('session/event', { id: 'a' }, { type: 'assistant/message', data: { message: { content: [{ type: 'text', text: '详细回复' }] } } }) + emit('session/event', session('a'), { type: 'assistant/message', data: { message: { content: [{ type: 'text', text: '详细回复' }] } } }) assert.deepEqual(rt.busyAcksView(), {}) }) -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, 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 } }) +test('a configured economy model is used for the ack', async () => { + const live = new Map([['a', { turn: 1 }]]) + const { rt, llm, emit } = harness(live, mockLlm(), { flybot: { modelFor: { 'busy-ack': 'p/flash' } } }) + installBusyAck(rt) + emit('session/event', session('a'), say('check the deploy logs')) + await settleAck(rt, 'a') + assert.equal(llm.calls[0].provider, 'p') + assert.equal(llm.calls[0].model, 'flash') +}) + +test('an error, a timeout or a too-slow model sends no text; the pending ack clears with the turn', async () => { + for (const llm of [mockLlm({ fail: true }), mockLlm({ hang: true }), mockLlm({ delay: ACK_TIMEOUT_MS + 500 })]) { + const live = new Map([['a', { turn: 1 }]]) + const { rt, emit } = harness(live, llm) + installBusyAck(rt) + const started = Date.now() + emit('session/event', session('a'), say('还在吗,顺便看下日志')) + assert.equal(await settleAck(rt, 'a'), '') + assert.ok(Date.now() - started < ACK_TIMEOUT_MS + 400, 'bounded by the hard timeout') + assert.equal(rt.busyAcksView().a.text, '') + emit('session/event', session('a'), { type: 'turn/end', data: { turn: 1 } }) assert.deepEqual(rt.busyAcksView(), {}) } - emit('session/event', { id: 'a' }, say('one more')) - assert.ok(rt.busyAcksView().a) +}) + +test('a late answer never revives a cleared ack', async () => { + const live = new Map([['a', { turn: 2 }]]) + const { rt, emit } = harness(live, mockLlm({ delay: 50 })) + installBusyAck(rt) + emit('session/event', session('a'), say('one more')) + const ready = rt.busyAckReady('a') emit('agent/status', { agent: { session: { id: 'a' } }, status: 'idle' }) + await ready assert.deepEqual(rt.busyAcksView(), {}) }) -// The quoted topic is the person's own text: a `{d}` / `{t}` they typed is not template -// markup and must be echoed unchanged. Messages stay short on purpose — topicOf() clips -// at TOPIC_MAX, and a long sentence would hide the rewrite behind the ellipsis. -test('a {d} typed in the message is quoted back verbatim, with and without a running tool', () => { - for (const message of ['改 {d} 这行', 'fix "{d}" here']) { - for (const tool of ['bash', undefined]) { - for (let seed = 0; seed < 6; seed += 1) { - const text = composeBusyAck({ message, tool, seed, uiLanguage: 'zh-CN' }) - assert.ok(text.includes('{d}'), `${message} / ${tool ?? 'no tool'} / seed ${seed}: ${text}`) - assert.ok(text.includes(topicOf(message, ackLanguage(message, 'zh-CN'))), `${message} / seed ${seed}: ${text}`) - } - } - } +test('no model route means no call and no text', async () => { + const live = new Map([['a', { turn: 1 }]]) + const { rt, llm, emit } = harness(live) + installBusyAck(rt) + emit('session/event', { id: 'a' }, say('hello there')) + assert.equal(await settleAck(rt, 'a'), '') + assert.equal(llm.calls.length, 0) }) -test('a {t} typed in the message is quoted back verbatim, with and without a running tool', () => { - for (const message of ['改 {t} 这行', 'fix "{t}" here']) { - for (const tool of ['bash', undefined]) { - for (let seed = 0; seed < 6; seed += 1) { - const text = composeBusyAck({ message, tool, seed, uiLanguage: 'zh-CN' }) - assert.ok(text.includes('{t}'), `${message} / ${tool ?? 'no tool'} / seed ${seed}: ${text}`) - assert.ok(text.includes(topicOf(message, ackLanguage(message, 'zh-CN'))), `${message} / seed ${seed}: ${text}`) - } - } - } -}) +const settleAck = async (rt, id) => (await rt.busyAckReady(id)) ?? '' // --- The room side (#1646) ------------------------------------------------- // A group's member speaks from a hidden Session (`room.sessions[botId]`), and the person's line @@ -185,6 +169,7 @@ const roomHost = async (t) => { const ctx = { logger: { warn() {}, info() {} }, effect(execute) { const dispose = execute(); if (typeof dispose === 'function') disposers.push(dispose) }, + llm: mockLlm({ reply: prompt => `ACK:${prompt.split('New message: ')[1]}` }), on(name, fn) { const list = listeners.get(name) ?? listeners.set(name, []).get(name); list.push(fn); return () => {} }, } const rt = { @@ -200,8 +185,8 @@ const roomHost = async (t) => { 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 } + const emit = (id, type, data) => fire('session/event', session(id), { type, seq: 1, data }) + return { rt, llm: ctx.llm, 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). @@ -227,14 +212,14 @@ test('a room line steered into a running member\'s Session notes one ack for tha 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/) + assert.equal(acks.gb.text, '') + assert.equal(await settleAck(host.rt, 'gb'), 'ACK:帮我把周报再改一下') + assert.doesNotMatch(host.llm.calls[0].messages[0].content[0].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.equal(host.rt.busyAcksView().gb.text, 'ACK:帮我把周报再改一下') assert.ok(host.rt.busyAcksView().gb.at >= at) // No ack survives the turn. host.emit('gb', 'turn/end', { turn: 1, reason: { kind: 'completed' } }) @@ -267,8 +252,8 @@ test('the room view names a running member before its line lands, then carries t // 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/) + await settleAck(host.rt, 'gb') + assert.equal(host.rt.roomBusyAcksView({ b: 'gb' }).b.text, 'ACK:帮我把周报再改一下') // No running turn, no entry; an empty member map stays empty. host.rt.live.delete('gb') assert.deepEqual(host.rt.roomBusyAcksView({ b: 'gb' }), {}) @@ -282,7 +267,7 @@ test('the own-chat ack is unchanged beside the room path (#1646)', async (t) => 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/) + assert.equal(await settleAck(host.rt, 'b'), 'ACK: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 })