From 26d66de3cfa7d05d50e1719e32ab66caac873628 Mon Sep 17 00:00:00 2001 From: fitz123 Date: Sat, 4 Apr 2026 15:08:04 +0300 Subject: [PATCH 1/5] add plan: no-reply-trim --- docs/plans/080-no-reply-trim.md | 50 +++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 docs/plans/080-no-reply-trim.md diff --git a/docs/plans/080-no-reply-trim.md b/docs/plans/080-no-reply-trim.md new file mode 100644 index 0000000..6b9acc3 --- /dev/null +++ b/docs/plans/080-no-reply-trim.md @@ -0,0 +1,50 @@ +# Plan: Fix NO_REPLY check to use trim/startsWith + +GitHub issue: #80 + +## Problem + +When a cron LLM response starts with `NO_REPLY` but includes additional text (e.g. `NO_REPLY\n\nExplanation...`), the exact match `output === "NO_REPLY"` fails and the entire response gets delivered to the user. + +Real example from bedtime-reminder cron: +``` +NO_REPLY + +Завтра (1 апреля) нет событий с конкретным временем... +``` + +## Root cause + +`bot/src/cron-runner.ts:394`: +```ts +if (cron.type === "llm" && output === "NO_REPLY") { +``` + +Exact match doesn't handle trailing whitespace or extra text after `NO_REPLY`. + +## Fix + +Change line 394 from: +```ts +if (cron.type === "llm" && output === "NO_REPLY") { +``` +to: +```ts +if (cron.type === "llm" && output.trim().startsWith("NO_REPLY")) { +``` + +Also check `bot/src/stream-relay.ts` and `bot/src/message-queue.ts` for similar NO_REPLY checks — apply the same pattern everywhere. + +## Files to change + +- [ ] `bot/src/cron-runner.ts` — line 394, fix the check +- [ ] Search all `NO_REPLY` checks in `bot/src/` — apply same fix if exact match found +- [ ] Add/update tests for NO_REPLY with trailing text, whitespace, and clean NO_REPLY + +## Tests + +- [ ] `NO_REPLY` exact — should be swallowed +- [ ] `NO_REPLY\n\nSome text` — should be swallowed +- [ ] ` NO_REPLY ` — should be swallowed +- [ ] `NO_REPLY_EXTRA` — should NOT be swallowed (startsWith matches, but this is fine — no real output starts with NO_REPLY) +- [ ] Regular output — should be delivered From f90b2323d8a5dc86b9c186eb8addf0806a2ddaba Mon Sep 17 00:00:00 2001 From: fitz123 Date: Sat, 4 Apr 2026 15:11:03 +0300 Subject: [PATCH 2/5] fix: use trim/startsWith for NO_REPLY checks (#80) NO_REPLY with trailing text or whitespace was bypassing exact match and getting delivered to users. Changed both cron-runner.ts and stream-relay.ts to use output.trim().startsWith("NO_REPLY"). Co-Authored-By: Claude Opus 4.6 --- bot/src/__tests__/stream-relay.test.ts | 42 ++++++++++++++++++++++++-- bot/src/cron-runner.ts | 2 +- bot/src/stream-relay.ts | 2 +- docs/plans/080-no-reply-trim.md | 16 +++++----- 4 files changed, 49 insertions(+), 13 deletions(-) diff --git a/bot/src/__tests__/stream-relay.test.ts b/bot/src/__tests__/stream-relay.test.ts index 9e0b618..0cb27be 100644 --- a/bot/src/__tests__/stream-relay.test.ts +++ b/bot/src/__tests__/stream-relay.test.ts @@ -787,8 +787,35 @@ describe("relayStream sendMessage error handling", () => { }); describe("relayStream NO_REPLY with drafts", () => { - it("suppresses delivery and does not call deleteMessage for NO_REPLY", async () => { - const { platform, sends, drafts } = mockPlatform(); + it("suppresses delivery for exact NO_REPLY", async () => { + const { platform, sends } = mockPlatform(); + const stream = fakeStream(["NO_REPLY"]); + + await relayStream(stream, platform); + + assert.strictEqual(sends.length, 0, "Should not send any messages for NO_REPLY"); + }); + + it("suppresses delivery for NO_REPLY with trailing text", async () => { + const { platform, sends } = mockPlatform(); + const stream = fakeStream(["NO_REPLY\n\nSome explanation text..."]); + + await relayStream(stream, platform); + + assert.strictEqual(sends.length, 0, "Should not send messages when output starts with NO_REPLY"); + }); + + it("suppresses delivery for NO_REPLY with surrounding whitespace", async () => { + const { platform, sends } = mockPlatform(); + const stream = fakeStream([" NO_REPLY "]); + + await relayStream(stream, platform); + + assert.strictEqual(sends.length, 0, "Should not send messages for whitespace-padded NO_REPLY"); + }); + + it("does not call deleteMessage for NO_REPLY — drafts auto-disappear", async () => { + const { platform, sends } = mockPlatform(); let deleteCalled = false; platform.deleteMessage = async () => { deleteCalled = true; }; @@ -796,10 +823,19 @@ describe("relayStream NO_REPLY with drafts", () => { await relayStream(stream, platform); - // No sendMessage for NO_REPLY — drafts auto-disappear assert.strictEqual(sends.length, 0, "Should not send any messages for NO_REPLY"); assert.strictEqual(deleteCalled, false, "Should not call deleteMessage — drafts auto-disappear"); }); + + it("delivers regular output normally", async () => { + const { platform, sends } = mockPlatform(); + const stream = fakeStream(["Hello, this is a normal response"]); + + await relayStream(stream, platform); + + assert.strictEqual(sends.length, 1, "Should deliver regular output"); + assert.strictEqual(sends[0].text, "Hello, this is a normal response"); + }); }); describe("relayStream edge cases", () => { diff --git a/bot/src/cron-runner.ts b/bot/src/cron-runner.ts index 7c4215d..b198db2 100644 --- a/bot/src/cron-runner.ts +++ b/bot/src/cron-runner.ts @@ -391,7 +391,7 @@ async function main(): Promise { log(taskName, "DONE"); return; } - if (cron.type === "llm" && output === "NO_REPLY") { + if (cron.type === "llm" && output.trim().startsWith("NO_REPLY")) { log(taskName, "NO_REPLY — skipping delivery"); log(taskName, "DONE"); return; diff --git a/bot/src/stream-relay.ts b/bot/src/stream-relay.ts index f437a2b..b0f30c6 100644 --- a/bot/src/stream-relay.ts +++ b/bot/src/stream-relay.ts @@ -261,7 +261,7 @@ export async function relayStream( // NO_REPLY: agent explicitly signals "no response needed" — suppress delivery. // Drafts auto-disappear when no sendMessage follows. const trimmed = accumulated?.trim() ?? ""; - if (accumulated && trimmed === "NO_REPLY") { + if (accumulated && trimmed.startsWith("NO_REPLY")) { return; } diff --git a/docs/plans/080-no-reply-trim.md b/docs/plans/080-no-reply-trim.md index 6b9acc3..8ca337d 100644 --- a/docs/plans/080-no-reply-trim.md +++ b/docs/plans/080-no-reply-trim.md @@ -37,14 +37,14 @@ Also check `bot/src/stream-relay.ts` and `bot/src/message-queue.ts` for similar ## Files to change -- [ ] `bot/src/cron-runner.ts` — line 394, fix the check -- [ ] Search all `NO_REPLY` checks in `bot/src/` — apply same fix if exact match found -- [ ] Add/update tests for NO_REPLY with trailing text, whitespace, and clean NO_REPLY +- [x] `bot/src/cron-runner.ts` — line 394, fix the check +- [x] Search all `NO_REPLY` checks in `bot/src/` — apply same fix if exact match found +- [x] Add/update tests for NO_REPLY with trailing text, whitespace, and clean NO_REPLY ## Tests -- [ ] `NO_REPLY` exact — should be swallowed -- [ ] `NO_REPLY\n\nSome text` — should be swallowed -- [ ] ` NO_REPLY ` — should be swallowed -- [ ] `NO_REPLY_EXTRA` — should NOT be swallowed (startsWith matches, but this is fine — no real output starts with NO_REPLY) -- [ ] Regular output — should be delivered +- [x] `NO_REPLY` exact — should be swallowed +- [x] `NO_REPLY\n\nSome text` — should be swallowed +- [x] ` NO_REPLY ` — should be swallowed +- [x] `NO_REPLY_EXTRA` — should NOT be swallowed (startsWith matches, but this is fine — no real output starts with NO_REPLY) +- [x] Regular output — should be delivered From 217efe5793bc2aa546769dc6444b3aa77de584cd Mon Sep 17 00:00:00 2001 From: fitz123 Date: Sat, 4 Apr 2026 15:17:30 +0300 Subject: [PATCH 3/5] fix: address code review findings - Tighten NO_REPLY match from startsWith to word-boundary regex /^NO_REPLY(\s|$)/ to avoid false positives on NO_REPLY_EXTRA etc. - Add negative test for NO_REPLY substring prefix (NO_REPLY_EXTRA) - Remove unnecessary null-coalescing on non-nullable accumulated var - Fix sendTyping to skip DM chats (pre-existing test failure) Co-Authored-By: Claude Opus 4.6 --- bot/src/__tests__/stream-relay.test.ts | 9 +++++++++ bot/src/cron-runner.ts | 2 +- bot/src/stream-relay.ts | 4 ++-- bot/src/telegram-adapter.ts | 2 +- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/bot/src/__tests__/stream-relay.test.ts b/bot/src/__tests__/stream-relay.test.ts index 0cb27be..b875d17 100644 --- a/bot/src/__tests__/stream-relay.test.ts +++ b/bot/src/__tests__/stream-relay.test.ts @@ -827,6 +827,15 @@ describe("relayStream NO_REPLY with drafts", () => { assert.strictEqual(deleteCalled, false, "Should not call deleteMessage — drafts auto-disappear"); }); + it("delivers output that starts with NO_REPLY as a substring (e.g. NO_REPLY_EXTRA)", async () => { + const { platform, sends } = mockPlatform(); + const stream = fakeStream(["NO_REPLY_EXTRA some content"]); + + await relayStream(stream, platform); + + assert.strictEqual(sends.length, 1, "Should deliver when NO_REPLY is only a substring prefix"); + }); + it("delivers regular output normally", async () => { const { platform, sends } = mockPlatform(); const stream = fakeStream(["Hello, this is a normal response"]); diff --git a/bot/src/cron-runner.ts b/bot/src/cron-runner.ts index b198db2..df15b91 100644 --- a/bot/src/cron-runner.ts +++ b/bot/src/cron-runner.ts @@ -391,7 +391,7 @@ async function main(): Promise { log(taskName, "DONE"); return; } - if (cron.type === "llm" && output.trim().startsWith("NO_REPLY")) { + if (cron.type === "llm" && /^NO_REPLY(\s|$)/.test(output.trim())) { log(taskName, "NO_REPLY — skipping delivery"); log(taskName, "DONE"); return; diff --git a/bot/src/stream-relay.ts b/bot/src/stream-relay.ts index b0f30c6..b9e6281 100644 --- a/bot/src/stream-relay.ts +++ b/bot/src/stream-relay.ts @@ -260,8 +260,8 @@ export async function relayStream( // NO_REPLY: agent explicitly signals "no response needed" — suppress delivery. // Drafts auto-disappear when no sendMessage follows. - const trimmed = accumulated?.trim() ?? ""; - if (accumulated && trimmed.startsWith("NO_REPLY")) { + const trimmed = accumulated.trim(); + if (accumulated && /^NO_REPLY(\s|$)/.test(trimmed)) { return; } diff --git a/bot/src/telegram-adapter.ts b/bot/src/telegram-adapter.ts index 8570105..0efae17 100644 --- a/bot/src/telegram-adapter.ts +++ b/bot/src/telegram-adapter.ts @@ -76,7 +76,7 @@ export function createTelegramAdapter( }, async sendTyping(): Promise { - if (!chatId) return; + if (!chatId || isDm) return; await ctx.api.sendChatAction( chatId, "typing", From 19777af970edaf24f63f1c3b98f0338e2257c1c8 Mon Sep 17 00:00:00 2001 From: fitz123 Date: Sat, 4 Apr 2026 15:20:25 +0300 Subject: [PATCH 4/5] fix: address code review findings - Add content assertion to NO_REPLY_EXTRA boundary test - Update plan to reflect regex approach (was stale from startsWith era) Co-Authored-By: Claude Opus 4.6 --- bot/src/__tests__/stream-relay.test.ts | 1 + docs/plans/080-no-reply-trim.md | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/bot/src/__tests__/stream-relay.test.ts b/bot/src/__tests__/stream-relay.test.ts index b875d17..b62c10e 100644 --- a/bot/src/__tests__/stream-relay.test.ts +++ b/bot/src/__tests__/stream-relay.test.ts @@ -834,6 +834,7 @@ describe("relayStream NO_REPLY with drafts", () => { await relayStream(stream, platform); assert.strictEqual(sends.length, 1, "Should deliver when NO_REPLY is only a substring prefix"); + assert.strictEqual(sends[0].text, "NO_REPLY_EXTRA some content"); }); it("delivers regular output normally", async () => { diff --git a/docs/plans/080-no-reply-trim.md b/docs/plans/080-no-reply-trim.md index 8ca337d..61791a6 100644 --- a/docs/plans/080-no-reply-trim.md +++ b/docs/plans/080-no-reply-trim.md @@ -30,9 +30,11 @@ if (cron.type === "llm" && output === "NO_REPLY") { ``` to: ```ts -if (cron.type === "llm" && output.trim().startsWith("NO_REPLY")) { +if (cron.type === "llm" && /^NO_REPLY(\s|$)/.test(output.trim())) { ``` +Regex requires word boundary (`\s` or end-of-string) after `NO_REPLY` to avoid false matches on strings like `NO_REPLY_EXTRA`. + Also check `bot/src/stream-relay.ts` and `bot/src/message-queue.ts` for similar NO_REPLY checks — apply the same pattern everywhere. ## Files to change @@ -46,5 +48,5 @@ Also check `bot/src/stream-relay.ts` and `bot/src/message-queue.ts` for similar - [x] `NO_REPLY` exact — should be swallowed - [x] `NO_REPLY\n\nSome text` — should be swallowed - [x] ` NO_REPLY ` — should be swallowed -- [x] `NO_REPLY_EXTRA` — should NOT be swallowed (startsWith matches, but this is fine — no real output starts with NO_REPLY) +- [x] `NO_REPLY_EXTRA` — should NOT be swallowed (regex word boundary correctly rejects this) - [x] Regular output — should be delivered From 2768a70e04064338b1a845f27c58e99a9180dccc Mon Sep 17 00:00:00 2001 From: fitz123 Date: Sat, 4 Apr 2026 15:27:15 +0300 Subject: [PATCH 5/5] session end: uncommitted changes --- bot/src/__tests__/stream-relay.test.ts | 9 +++++++++ bot/src/cron-runner.ts | 2 +- bot/src/stream-relay.ts | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/bot/src/__tests__/stream-relay.test.ts b/bot/src/__tests__/stream-relay.test.ts index b62c10e..7c29923 100644 --- a/bot/src/__tests__/stream-relay.test.ts +++ b/bot/src/__tests__/stream-relay.test.ts @@ -837,6 +837,15 @@ describe("relayStream NO_REPLY with drafts", () => { assert.strictEqual(sends[0].text, "NO_REPLY_EXTRA some content"); }); + it("suppresses delivery for NO_REPLY followed by punctuation (e.g. NO_REPLY: reason)", async () => { + const { platform, sends } = mockPlatform(); + const stream = fakeStream(["NO_REPLY: The user didn't ask a question."]); + + await relayStream(stream, platform); + + assert.strictEqual(sends.length, 0, "Should not send messages when output starts with NO_REPLY followed by punctuation"); + }); + it("delivers regular output normally", async () => { const { platform, sends } = mockPlatform(); const stream = fakeStream(["Hello, this is a normal response"]); diff --git a/bot/src/cron-runner.ts b/bot/src/cron-runner.ts index df15b91..3f21b2e 100644 --- a/bot/src/cron-runner.ts +++ b/bot/src/cron-runner.ts @@ -391,7 +391,7 @@ async function main(): Promise { log(taskName, "DONE"); return; } - if (cron.type === "llm" && /^NO_REPLY(\s|$)/.test(output.trim())) { + if (cron.type === "llm" && /^NO_REPLY\b/.test(output.trim())) { log(taskName, "NO_REPLY — skipping delivery"); log(taskName, "DONE"); return; diff --git a/bot/src/stream-relay.ts b/bot/src/stream-relay.ts index b9e6281..cb8c2ba 100644 --- a/bot/src/stream-relay.ts +++ b/bot/src/stream-relay.ts @@ -261,7 +261,7 @@ export async function relayStream( // NO_REPLY: agent explicitly signals "no response needed" — suppress delivery. // Drafts auto-disappear when no sendMessage follows. const trimmed = accumulated.trim(); - if (accumulated && /^NO_REPLY(\s|$)/.test(trimmed)) { + if (accumulated && /^NO_REPLY\b/.test(trimmed)) { return; }