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/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}`) + } + } + } +}) 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}`) + } + } + } +})