From 4048f8dd2c9d417b2c26939f2f8d1b5b81610f16 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:39:51 +0800 Subject: [PATCH 1/2] test(chat): pin a {d} typed in the message surviving the busy ack composeBusyAck() substituted {t} and then scanned the result for {d}, so a {d} the person typed inside their own quoted message was rewritten to the running tool's words (or dropped when no tool runs). The new cases keep the message short so topicOf()'s clip cannot hide the rewrite, and cover {d} and {t} with and without a running tool. --- core/test/busy-ack.test.js | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/core/test/busy-ack.test.js b/core/test/busy-ack.test.js index cb3170fd0..9700f81d4 100644 --- a/core/test/busy-ack.test.js +++ b/core/test/busy-ack.test.js @@ -124,3 +124,30 @@ test('consecutive acks in one chat differ, and clear on turn/end and agent idle' emit('agent/status', { agent: { session: { id: 'a' } }, status: 'idle' }) 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('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}`) + } + } + } +}) From 8e9684d53bd03a9f4d9f5526f88f901367b2771f Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:42:38 +0800 Subject: [PATCH 2/2] fix(chat): quote a {d}/{t} the person typed instead of filling it composeBusyAck() substituted {t} first and then scanned the result for {d}, so the topic taken from the person's message was treated as template markup: a {d} they typed became the running tool's words, or disappeared with no tool running. Fill in one pass so inserted text is never rescanned, and say in the core-host doc that the quoted topic is verbatim. --- .../1634-busy-ack-placeholder.fixed.md | 1 + core/src/host/busy-ack.js | 4 ++- docs/subsystems/core-host.md | 2 +- patches/ds-bot/files/src/host/busy-ack.js | 4 ++- patches/ds-bot/files/test/busy-ack.test.js | 27 +++++++++++++++++++ 5 files changed, 35 insertions(+), 3 deletions(-) create mode 100644 changelog.d/1634-busy-ack-placeholder.fixed.md diff --git a/changelog.d/1634-busy-ack-placeholder.fixed.md b/changelog.d/1634-busy-ack-placeholder.fixed.md new file mode 100644 index 000000000..32f2fa1f3 --- /dev/null +++ b/changelog.d/1634-busy-ack-placeholder.fixed.md @@ -0,0 +1 @@ +The busy ack now quotes your own message exactly: typing `{d}` or `{t}` in a line no longer makes the ack swap in the running tool's words (or drop it) ([#1634](https://github.com/ThinkFlowLab/FlyBot/issues/1634)). diff --git a/core/src/host/busy-ack.js b/core/src/host/busy-ack.js index 2e2cca0a6..2cd736d31 100644 --- a/core/src/host/busy-ack.js +++ b/core/src/host/busy-ack.js @@ -114,7 +114,9 @@ export function composeBusyAck({ message, tool, uiLanguage, seed = 0, previous } 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 ?? '') + // 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) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 9b482b879..32b255a32 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -405,7 +405,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi - A friend's line in a Bot's own chat (#420): `deliver()` (`noteFriendLine`) stamps the receiving Bot with `friendLineAt` (published in the roster, so the save moves the revision) and keeps the senders seen per Bot in `state.remoteContacts[botId][handle]` (host only, capped at 200, dropped with the Bot). A sender's first line carries `source.remote.firstContact`. Group lines, and re-deliveries (`deferred`, `resumed`, `retried`, `resent`), stamp nothing. Covered by `core/test/flybot.test.js`. ## Busy-send acknowledgment (#1584) -When a human `user/message` lands in a Bot's own chat while that Session already has a live turn, the host records a short spoken acknowledgment on the live payload (`busyAcks`). The line is composed on the host with no model call ([#1629](https://github.com/ThinkFlowLab/FlyBot/issues/1629)): it echoes a clipped topic from the message 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`). 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`. - **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/files/src/host/busy-ack.js b/patches/ds-bot/files/src/host/busy-ack.js index 2e2cca0a6..2cd736d31 100644 --- a/patches/ds-bot/files/src/host/busy-ack.js +++ b/patches/ds-bot/files/src/host/busy-ack.js @@ -114,7 +114,9 @@ export function composeBusyAck({ message, tool, uiLanguage, seed = 0, previous } 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 ?? '') + // 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) diff --git a/patches/ds-bot/files/test/busy-ack.test.js b/patches/ds-bot/files/test/busy-ack.test.js index cb3170fd0..9700f81d4 100644 --- a/patches/ds-bot/files/test/busy-ack.test.js +++ b/patches/ds-bot/files/test/busy-ack.test.js @@ -124,3 +124,30 @@ test('consecutive acks in one chat differ, and clear on turn/end and agent idle' emit('agent/status', { agent: { session: { id: 'a' } }, status: 'idle' }) 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('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}`) + } + } + } +})