Skip to content

fix(bot): coalesce Telegram-split messages into a single prompt#180

Open
8x22b wants to merge 3 commits into
grinev:mainfrom
8x22b:fix/merge-telegram-split-messages
Open

fix(bot): coalesce Telegram-split messages into a single prompt#180
8x22b wants to merge 3 commits into
grinev:mainfrom
8x22b:fix/merge-telegram-split-messages

Conversation

@8x22b

@8x22b 8x22b commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Problem

Telegram delivers long text — and a single paste — as several consecutive message updates (the ~4096-char client split, plus some clients splitting one input into multiple messages). The bot dispatches each update as its own prompt, so one long message becomes several independent agent runs and the conversation breaks.

Fix

Buffer plain-text prompts per chat for a short window (MESSAGE_MERGE_WINDOW_MS, default 1500; 0 disables). Each new chunk restarts the window; once it elapses with no new chunk, the buffered texts are joined (\n\n) and dispatched as a single session.prompt.

Only the final plain-text fallback path is buffered. Commands and active interaction flows (question answers, task/model-search/rename input, command/skill arguments) keep being handled immediately and are never buffered.

Because the window restarts on every chunk, messages sent a few seconds apart are still processed independently.

Config

Var Default Notes
MESSAGE_MERGE_WINDOW_MS 1500 0 disables merging and processes every message immediately

Tradeoffs

  • Chunks are joined with \n\n. For a 4096-char split this adds one blank line per boundary (negligible in a long prompt); for genuinely separate quick messages it reads as separate paragraphs. Easy to change the separator if a maintainer prefers.
  • Merging is on by default so the split-message bug is fixed without extra config. If default-off is preferred, it's a one-line flip.

Tests

New tests/bot/handlers/message-merger.test.ts covers: disabled window (immediate), single buffered message, multi-chunk merge with window restart, gap-beyond-window flushes separately, per-chat independence, manual flushPendingPrompt, and latest ctx used on merge. The merger state is also reset in tests/helpers/reset-singleton-state.ts so timers can't leak between tests.

npm run lint, npm run build, npm test (1095 tests) pass. No OS-specific code.

Telegram delivers long text (and a single paste) as several consecutive message updates, which the bot dispatched as separate prompts - one agent run per chunk.

Buffer plain-text prompts per chat for MESSAGE_MERGE_WINDOW_MS (default 1500; 0 disables). Each new chunk restarts the window; when it elapses, buffered texts are joined and dispatched as one prompt. Commands and active interaction flows (question/task/rename/catalog) are unaffected.
@grinev

grinev commented Jul 11, 2026

Copy link
Copy Markdown
Owner

@8x22b thanks for the PR! The core idea (debouncing Telegram-split chunks into one prompt) is solid, and the merger logic is nicely tested. A few things before I'd merge:

  1. Only buffer large/suspicious messages
    Right now every plain-text prompt waits the full window before dispatch, including short ones. Since most users send short messages most of the time, this adds a constant latency tax to the common case in order to fix a rare edge case.
    I'd prefer starting the merge window only when the text looks like a split chunk — i.e. its length is close to Telegram's ~4096-char limit. Short messages would go straight to processUserPrompt with no delay. We'll miss an occasional short client-side split, but that's an acceptable trade-off — the user just waits a bit in those rare cases. As a bonus, the exact window value becomes much less important, since it only triggers for large messages (so 1500ms is fine again).

  2. Flush the buffer before context-changing actions
    flushPendingPrompt is exported but never called in production code. Since processUserPrompt resolves the current session at call time (not when the message arrived), a /new, /sessions, or project switch landing inside the window would dispatch the buffered prompt into the wrong session. Please call flushPendingPrompt(chatId) at the start of the command router.

  3. Flush before attachment handlers too
    Photos, documents, voice, and media groups still call processUserPrompt directly and immediately. If a user sends "analyze this" + a photo back to back, the photo runs first, marks the session busy, and the delayed text then hits "session busy" and gets dropped. Flushing the pending text in those handlers before processing the attachment fixes the ordering and the lost prompt.

  4. Catch errors from the void calls
    The router used to await processUserPrompt, so grammY handled rejections from the early section (before the internal try/catch). Now those turn into unhandled rejections. A .catch(err => logger.error(...)) on both void processUserPrompt(...) calls in message-merger.ts would restore that.

  5. README table
    The rows around MESSAGE_FORMAT_MODE / MESSAGE_MERGE_WINDOW_MS lost their leading | alignment, so the table renders broken. Please realign the columns.

@8x22b

8x22b commented Jul 11, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, addressed all five points. Short prompts now go straight through; only messages at least 4000 characters long open the merge window, while short follow-up chunks still join an active buffer. Pending text is flushed before slash commands and photo/document/voice/audio/media-group handling, both background prompt calls catch and log errors, and the README table is fixed. I added coverage for the new paths as well. Build, lint, and the full test suite (1119 tests) pass locally. I also merged the current main branch to resolve the PR conflicts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants