Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 58 additions & 3 deletions bot/src/__tests__/stream-relay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -787,19 +787,74 @@ 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; };

const stream = fakeStream(["NO_REPLY"]);

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", () => {
Expand Down
2 changes: 1 addition & 1 deletion bot/src/cron-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,7 @@ async function main(): Promise<void> {
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;
Expand Down
4 changes: 2 additions & 2 deletions bot/src/stream-relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
2 changes: 1 addition & 1 deletion bot/src/telegram-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export function createTelegramAdapter(
},

async sendTyping(): Promise<void> {
if (!chatId) return;
if (!chatId || isDm) return;
await ctx.api.sendChatAction(
chatId,
"typing",
Expand Down
52 changes: 52 additions & 0 deletions docs/plans/080-no-reply-trim.md
Original file line number Diff line number Diff line change
@@ -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
Loading