#11653: fix(telegram): retry without message_thread_id on stale forum thread error
channel: telegram
stale
Cluster:
Messaging Platform Improvements
## Summary
Fixes #11620 — The `message` tool fails with `400: Bad Request: message thread not found` when sending to a Telegram chat where Forum/Topics mode was previously enabled and then disabled.
## Root Cause
When Forum/Topics mode is toggled off in a Telegram chat, the `message_thread_id` persisted in the session store (`lastThreadId` via `deliveryContextFromSession()`) becomes stale. The outbound send path unconditionally includes this stale thread ID in API calls:
1. `resolveSessionDeliveryTarget()` reads `lastThreadId` from session store
2. `deliverOutboundPayloads()` passes it to the Telegram outbound adapter
3. `sendMessageTelegram()` builds `{ message_thread_id: <stale_id> }` and includes it in the API call
4. Telegram rejects with `400: Bad Request: message thread not found`
This affects all send types: `sendMessage`, `sendPhoto`, `sendVideo`, `sendAnimation`, `sendDocument`, `sendAudio`, `sendVoice`.
Notably, **agent auto-replies work fine** because the inbound path reads `is_forum` from each incoming message and only builds `threadSpec` when the chat is actually a forum. But the `message` tool's outbound path trusts the session store blindly.
## Fix
Added automatic retry logic in `sendMessageTelegram()`: when the Telegram API returns `400: Bad Request: message thread not found` **and** the request included a `message_thread_id`, the function retries once with the thread parameter removed. This ensures messages still deliver even when the session state contains an outdated thread ID.
The fix is applied at the `sendMessageTelegram` level so all send types benefit from the same retry behavior without modifying each individual call site.
## Changes
| File | Change |
|------|--------|
| `src/telegram/send.ts` | Add `THREAD_NOT_FOUND_RE` regex, `isThreadNotFoundError()` check, and try/catch retry wrapper around send logic (+30 lines) |
| `src/telegram/send.thread-not-found-retry.test.ts` | 4 new unit tests |
## Tests
4 unit tests covering:
- **Text message retry**: sends with `message_thread_id` → gets "thread not found" → retries without it → succeeds
- **No retry for unrelated errors**: `chat not found` is not retried (only called once)
- **No retry without thread ID**: if no `message_thread_id` was set, the error propagates normally
- **Media retry**: `sendPhoto` with thread ID → "thread not found" → retries without thread → succeeds
```
✓ src/telegram/send.thread-not-found-retry.test.ts (4 tests) 63ms
Test Files 1 passed (1)
Tests 4 passed (4)
```
## Design Decision
This retry-at-send approach was chosen over alternatives because:
- **Minimal blast radius**: only affects sends that actually fail with this specific error
- **No session store migration needed**: stale `lastThreadId` values self-heal on next send
- **Covers all send types**: text, photo, video, audio, document, animation, voice
- **Safe**: the retry is bounded (once only) and only triggered for the exact error pattern
<!-- greptile_comment -->
<h2>Greptile Overview</h2>
<h3>Greptile Summary</h3>
This PR adds a targeted retry in `src/telegram/send.ts` to handle Telegram’s `400: Bad Request: message thread not found` error when a stale `message_thread_id` is included (e.g., after Forum/Topics mode is disabled). It detects that specific error and retries once with the thread parameter removed, and introduces a new Vitest suite (`src/telegram/send.thread-not-found-retry.test.ts`) to validate retry behavior for text and media sends.
Overall the approach is consistent with the existing outbound send flow (centralized in `sendMessageTelegram()` so all send types benefit) and keeps the retry bounded in intent; however, the current implementation uses recursion without a hard guard, and the retry can still re-derive a thread id from `to` via `parseTelegramTarget()`, which undermines the “retry without thread” guarantee in some inputs.
<h3>Confidence Score: 3/5</h3>
- Reasonably safe change, but retry logic has a clear edge case that can cause repeated retries/recursion in some inputs.
- The fix is narrowly scoped and covered by unit tests, but `sendMessageTelegram()` retries by recursively calling itself without an explicit single-attempt guard and may reintroduce `message_thread_id` via `parseTelegramTarget(to)` on the retry call. Those issues can lead to repeated failures or unbounded recursion in the exact scenario this PR is meant to harden.
- src/telegram/send.ts; src/telegram/send.thread-not-found-retry.test.ts
<!-- greptile_other_comments_section -->
<!-- /greptile_comment -->
Most Similar PRs
#14443: fix(telegram): skip General topic thread ID for all chat types (#14...
by lailoo · 2026-02-12
81.8%
#17968: fix(telegram): restore DM topic thread ids with send-path fallback
by Leonccaa · 2026-02-16
81.4%
#7261: fix(telegram): preserve DM topic thread id for outbound media
by ViffyGwaanl · 2026-02-02
81.0%
#12936: fix(telegram): omit message_thread_id for private DM chats
by omair445 · 2026-02-09
80.9%
#19050: fix(telegram): skip message_thread_id for private chats to prevent ...
by Limitless2023 · 2026-02-17
80.8%
#19213: Telegram: preserve DM topic thread in direct replies
by Kemalau · 2026-02-17
80.7%
#19108: fix(telegram): fix send failure in closed forum topics
by Clawborn · 2026-02-17
78.5%
#11340: Telegram: skip empty message text instead of throwing (#11238)
by lailoo · 2026-02-07
77.8%
#16996: fix(cron): parse Telegram topic target format for announce delivery
by Glucksberg · 2026-02-15
77.6%
#16548: fix(telegram): enhance chat_id validation and diagnostics
by tanujbhaud · 2026-02-14
77.4%