Debounce Telegram messages into a single turn (Message Burst collation) #51

Closed
opened 2026-07-06 22:59:52 +08:00 by weiwen · 0 comments
Owner

Problem Statement

When I paste a long message to evie on Telegram, the Telegram client silently splits it into several separate messages (Telegram caps a message at 4096 characters and delivers each piece as its own message, in rapid succession). Evie treats each piece as a brand-new prompt: the second piece aborts the turn started by the first, the third aborts the second, and so on. The net result is that evie only ever answers the last fragment of my paste, and the earlier fragments are stranded as aborted partials in the session history. The same thing happens when I send a photo album — each photo arrives as a separate message, so only the last photo is actually processed.

Solution

Evie collates a Message Burst — a run of Telegram messages that arrive in quick succession — into a single prompt for one turn. Incoming Telegram messages are held in a per-chat buffer; once the chat has been quiet for a short window (500ms), the buffered messages are dispatched together as one turn. Text parts are joined into one prompt and any attachments (photos, documents) are combined, so a split paste is reassembled and an album is processed as a single multi-image turn. From my perspective, pasting a long message or sending an album "just works": evie answers the whole thing once, instead of only the tail.

User Stories

  1. As an evie user, I want a long pasted message that Telegram splits into multiple pieces to be answered as a single prompt, so that evie responds to my whole message instead of only the last fragment.
  2. As an evie user, I want the fragments of a split paste reassembled in the order I sent them, so that the reconstructed prompt reads correctly.
  3. As an evie user, I want to send several short messages in quick succession and have them treated as one turn, so that I don't get one truncated answer per line.
  4. As an evie user, I want a photo album (multiple photos sent together) processed as one turn containing all the photos, so that evie sees every image instead of only the last one.
  5. As an evie user, I want a message that mixes text and an attachment within a burst (e.g. a photo with a caption followed by a follow-up line) collated into one turn, so that the caption, image, and follow-up are all considered together.
  6. As an evie user, I want the collation window to reset each time a new piece arrives, so that a paste that takes slightly longer than the window to arrive is still gathered into one turn rather than split.
  7. As an evie user, I want a single message (the common case) to still be answered normally, so that ordinary conversation is unaffected beyond an imperceptible delay.
  8. As an evie user, I want a burst that arrives while evie is still answering a previous turn to abort that turn and replace it, so that the last thing I said is what evie responds to (unchanged last-writer-wins behaviour).
  9. As an evie user, I want /clear sent during a burst to discard the pending buffered messages, so that a stale burst does not fire a ghost turn onto my freshly-cleared session.
  10. As an evie user, I want /clear, /help, and /start to continue to work immediately and never be swept into a collated prompt, so that commands behave predictably.
  11. As an evie user, I want the delay introduced by collation to be short enough to be imperceptible, so that evie still feels responsive.
  12. As an evie user, I want the placeholder and typing indicator to appear when evie actually starts working on the collated turn, so that the on-screen feedback reflects the real turn.
  13. As an evie user, I want a burst that finishes dispatching, followed by a new message, to start a fresh turn, so that no messages are ever silently dropped.
  14. As an evie user on the HTTP API, I want my behaviour unchanged, so that a single API request continues to be one prompt (the splitting problem does not exist there).
  15. As an evie developer, I want the collation logic to be a pure, unit-tested function, so that the join/concatenation behaviour is verifiable without spinning up Telegram or pi.
  16. As an evie developer, I want the debounce state contained within the Telegram module, so that the rest of the system is unaffected.

Implementation Decisions

  • Scope: Telegram only. The HTTP interface takes one request = one prompt with no client-side splitting, so it is untouched. All changes live in the Telegram module.
  • Message Burst (new domain term). A run of Telegram messages arriving in quick succession, collated into a single prompt for one turn. Defined purely by arrival timing — evie cannot distinguish a split paste from two genuinely separate messages typed within the window, and treats both as one turn. Recorded in CONTEXT.md.
  • Flush rule: trailing debounce, 500ms, reset on each message. The buffer opens on the first message of a burst; each subsequent message resets a 500ms timer; the burst is dispatched once 500ms elapse with no new message. No absolute max-window cap (the pathological never-quiet stream does not occur in practice).
  • Debounce window is a hardcoded constant (DEBOUNCE_WINDOW, 500ms), alongside the existing EDIT_THROTTLE/TYPING_REFRESH constants — not a configuration field. It tracks Telegram client timing, not a user preference.
  • Collation: join text parts with \n\n; concatenate attachments. Text fragments are joined with a blank-line separator, in arrival order. Image/attachment lists are concatenated across the burst. Accepted trade-off: a split code block or long prose paste gets a blank line injected at each 4096-character cut. (The alternative "4096-boundary heuristic" was considered and rejected — see ADR 0005.)
  • The buffer sits in front of the existing abort-and-replace logic. The current deliver_streaming path — which aborts any in-flight turn as its first act — is unchanged. On flush, the collated burst is handed to deliver_streaming exactly as a single message is today, so a burst arriving mid-stream still aborts the running turn (at flush time). Every incoming message now waits up to ~500ms before anything happens, including a lone message sent to interrupt a streaming turn; this latency is accepted as imperceptible against multi-second turns.
  • Uniform buffering. Text, photos, and documents all buffer identically. The flush task owns the accumulated attachment temp-file guards and keeps them alive for the duration of the pi turn (the same lifetime discipline as the current single-message path, relocated into the flush task).
  • Structure: a per-chat debounce map as a dispatcher dependency. A Mutex-guarded map keyed by Telegram chat id, holding a PendingBurst (accumulated text parts, images, temp guards, and the current deadline), mirroring the existing per-chat TurnTracker dependency. Threaded through the dispatcher and into the message handler.
  • Mechanism: deadline + a single owned flush task per burst. On each content message, under the map lock: create the PendingBurst and spawn exactly one flush task if none exists, otherwise append content; either way set the deadline to now + 500ms. The flush task loops — read the current deadline, sleep until it, then under the lock check whether a newer message pushed the deadline out; if not, remove and take the burst (task ends) and dispatch it; if so, loop and sleep to the new deadline. At-most-one dispatch per burst; reset-on-message is the deadline bump. Concurrency-safe against the dispatcher's distribution_function(|_| None), which lets multiple handler tasks for the same chat run in parallel.
  • Command handling. Commands continue to be intercepted before buffering (never collated). /clear additionally removes the chat's PendingBurst under the map lock so its flush task finds no entry and exits without dispatching — preventing a ghost turn on the freshly-cleared session. /help//start/unknown reply immediately and leave any in-progress burst intact.
  • UX during the window. Nothing is shown during the 500ms window; the placeholder and typing indicator appear when deliver_streaming runs at flush, exactly as today.

Testing Decisions

  • Test external behaviour, not implementation. Tests target the observable contract of collation — what prompt text and what image set result from a given sequence of buffered messages — not the internal timer plumbing.
  • Primary seam: a pure collate function. The entire correctness surface of the join/concatenation rules is extracted into a pure, synchronous pub(super) function that takes the accumulated burst parts and returns the collated (text, images). This mirrors the codebase's established pure-helper pattern (markdown::paginate, view::reconcile, view::plan_plain), each of which is a pure function with a co-located #[cfg(test)] mod tests block.
  • Cases to cover for collate: a single message passes through unchanged; N text parts join with \n\n in order; images concatenate across messages; a text+image mix preserves both; arrival order is preserved; empty/edge inputs behave sensibly.
  • Prior art: the unit-test blocks in src/telegram/markdown.rs and src/telegram/view.rs (e.g. prefix_stable_seal_skips_unchanged_leading_pages, plan_plain_sends_when_view_empty) — same shape: pure function, table-ish assertions, no async or teloxide.
  • Deliberately not seamed: the debounce timing. The deadline loop, reset-on-message, and single-flush-task lifecycle are inherently async and stateful; a meaningful test would require tokio::time::pause/advance and would largely assert tokio's own behaviour. Consistent with the existing untested async delivery loop (deliver_streaming), the timing stays unit-untested; only the pure collate helper is tested.

Out of Scope

  • The HTTP API path (no splitting problem there).
  • The 4096-boundary heuristic for perfectly reconstructing split pastes without an injected blank line — considered and rejected in ADR 0005; may be revisited if \n\n-join mangling of pasted code proves annoying in practice.
  • Making the debounce window user-configurable.
  • Any change to the abort/steer semantics, pi's RPC steering, or the scheduled/silent delivery path.
  • Fixing the pre-existing CONTEXT.md "Command" entry contradiction (unknown commands are replied to, not forwarded to pi) — surfaced during grilling, filed as a separate follow-up issue.

Further Notes

  • Recorded in ADR 0005 — Debounce Telegram messages into a single turn (docs/adr/0005-telegram-message-burst-debounce.md), which captures the 500ms trailing-debounce decision, the \n\n join and its trade-off, the rejected 4096-boundary heuristic, and the accepted ~500ms latency on every message.
  • CONTEXT.md gained the Message Burst glossary term and its Session entry now references bursts.
  • Key consequence to keep in mind during review: a burst is defined purely by arrival timing. Two genuinely separate messages typed within 500ms of each other will be merged into one turn — this is intended and unavoidable given evie cannot distinguish them from a split paste.
## Problem Statement When I paste a long message to evie on Telegram, the Telegram client silently splits it into several separate messages (Telegram caps a message at 4096 characters and delivers each piece as its own message, in rapid succession). Evie treats each piece as a brand-new prompt: the second piece aborts the turn started by the first, the third aborts the second, and so on. The net result is that evie only ever answers the *last* fragment of my paste, and the earlier fragments are stranded as aborted partials in the session history. The same thing happens when I send a photo album — each photo arrives as a separate message, so only the last photo is actually processed. ## Solution Evie collates a **Message Burst** — a run of Telegram messages that arrive in quick succession — into a single prompt for one turn. Incoming Telegram messages are held in a per-chat buffer; once the chat has been quiet for a short window (500ms), the buffered messages are dispatched together as one turn. Text parts are joined into one prompt and any attachments (photos, documents) are combined, so a split paste is reassembled and an album is processed as a single multi-image turn. From my perspective, pasting a long message or sending an album "just works": evie answers the whole thing once, instead of only the tail. ## User Stories 1. As an evie user, I want a long pasted message that Telegram splits into multiple pieces to be answered as a single prompt, so that evie responds to my whole message instead of only the last fragment. 2. As an evie user, I want the fragments of a split paste reassembled in the order I sent them, so that the reconstructed prompt reads correctly. 3. As an evie user, I want to send several short messages in quick succession and have them treated as one turn, so that I don't get one truncated answer per line. 4. As an evie user, I want a photo album (multiple photos sent together) processed as one turn containing all the photos, so that evie sees every image instead of only the last one. 5. As an evie user, I want a message that mixes text and an attachment within a burst (e.g. a photo with a caption followed by a follow-up line) collated into one turn, so that the caption, image, and follow-up are all considered together. 6. As an evie user, I want the collation window to reset each time a new piece arrives, so that a paste that takes slightly longer than the window to arrive is still gathered into one turn rather than split. 7. As an evie user, I want a single message (the common case) to still be answered normally, so that ordinary conversation is unaffected beyond an imperceptible delay. 8. As an evie user, I want a burst that arrives while evie is still answering a previous turn to abort that turn and replace it, so that the last thing I said is what evie responds to (unchanged last-writer-wins behaviour). 9. As an evie user, I want `/clear` sent during a burst to discard the pending buffered messages, so that a stale burst does not fire a ghost turn onto my freshly-cleared session. 10. As an evie user, I want `/clear`, `/help`, and `/start` to continue to work immediately and never be swept into a collated prompt, so that commands behave predictably. 11. As an evie user, I want the delay introduced by collation to be short enough to be imperceptible, so that evie still feels responsive. 12. As an evie user, I want the placeholder and typing indicator to appear when evie actually starts working on the collated turn, so that the on-screen feedback reflects the real turn. 13. As an evie user, I want a burst that finishes dispatching, followed by a new message, to start a fresh turn, so that no messages are ever silently dropped. 14. As an evie user on the HTTP API, I want my behaviour unchanged, so that a single API request continues to be one prompt (the splitting problem does not exist there). 15. As an evie developer, I want the collation logic to be a pure, unit-tested function, so that the join/concatenation behaviour is verifiable without spinning up Telegram or pi. 16. As an evie developer, I want the debounce state contained within the Telegram module, so that the rest of the system is unaffected. ## Implementation Decisions - **Scope: Telegram only.** The HTTP interface takes one request = one prompt with no client-side splitting, so it is untouched. All changes live in the Telegram module. - **Message Burst (new domain term).** A run of Telegram messages arriving in quick succession, collated into a single prompt for one turn. Defined purely by arrival timing — evie cannot distinguish a split paste from two genuinely separate messages typed within the window, and treats both as one turn. Recorded in `CONTEXT.md`. - **Flush rule: trailing debounce, 500ms, reset on each message.** The buffer opens on the first message of a burst; each subsequent message resets a 500ms timer; the burst is dispatched once 500ms elapse with no new message. No absolute max-window cap (the pathological never-quiet stream does not occur in practice). - **Debounce window is a hardcoded constant** (`DEBOUNCE_WINDOW`, 500ms), alongside the existing `EDIT_THROTTLE`/`TYPING_REFRESH` constants — not a configuration field. It tracks Telegram client timing, not a user preference. - **Collation: join text parts with `\n\n`; concatenate attachments.** Text fragments are joined with a blank-line separator, in arrival order. Image/attachment lists are concatenated across the burst. Accepted trade-off: a split code block or long prose paste gets a blank line injected at each 4096-character cut. (The alternative "4096-boundary heuristic" was considered and rejected — see ADR 0005.) - **The buffer sits in front of the existing abort-and-replace logic.** The current `deliver_streaming` path — which aborts any in-flight turn as its first act — is unchanged. On flush, the collated burst is handed to `deliver_streaming` exactly as a single message is today, so a burst arriving mid-stream still aborts the running turn (at flush time). Every incoming message now waits up to ~500ms before anything happens, including a lone message sent to interrupt a streaming turn; this latency is accepted as imperceptible against multi-second turns. - **Uniform buffering.** Text, photos, and documents all buffer identically. The flush task owns the accumulated attachment temp-file guards and keeps them alive for the duration of the pi turn (the same lifetime discipline as the current single-message path, relocated into the flush task). - **Structure: a per-chat debounce map as a dispatcher dependency.** A `Mutex`-guarded map keyed by Telegram chat id, holding a `PendingBurst` (accumulated text parts, images, temp guards, and the current deadline), mirroring the existing per-chat `TurnTracker` dependency. Threaded through the dispatcher and into the message handler. - **Mechanism: deadline + a single owned flush task per burst.** On each content message, under the map lock: create the `PendingBurst` and spawn exactly one flush task if none exists, otherwise append content; either way set the deadline to now + 500ms. The flush task loops — read the current deadline, sleep until it, then under the lock check whether a newer message pushed the deadline out; if not, remove and take the burst (task ends) and dispatch it; if so, loop and sleep to the new deadline. At-most-one dispatch per burst; reset-on-message is the deadline bump. Concurrency-safe against the dispatcher's `distribution_function(|_| None)`, which lets multiple handler tasks for the same chat run in parallel. - **Command handling.** Commands continue to be intercepted before buffering (never collated). `/clear` additionally removes the chat's `PendingBurst` under the map lock so its flush task finds no entry and exits without dispatching — preventing a ghost turn on the freshly-cleared session. `/help`/`/start`/unknown reply immediately and leave any in-progress burst intact. - **UX during the window.** Nothing is shown during the 500ms window; the placeholder and typing indicator appear when `deliver_streaming` runs at flush, exactly as today. ## Testing Decisions - **Test external behaviour, not implementation.** Tests target the observable contract of collation — what prompt text and what image set result from a given sequence of buffered messages — not the internal timer plumbing. - **Primary seam: a pure `collate` function.** The entire correctness surface of the join/concatenation rules is extracted into a pure, synchronous `pub(super)` function that takes the accumulated burst parts and returns the collated `(text, images)`. This mirrors the codebase's established pure-helper pattern (`markdown::paginate`, `view::reconcile`, `view::plan_plain`), each of which is a pure function with a co-located `#[cfg(test)] mod tests` block. - **Cases to cover for `collate`:** a single message passes through unchanged; N text parts join with `\n\n` in order; images concatenate across messages; a text+image mix preserves both; arrival order is preserved; empty/edge inputs behave sensibly. - **Prior art:** the unit-test blocks in `src/telegram/markdown.rs` and `src/telegram/view.rs` (e.g. `prefix_stable_seal_skips_unchanged_leading_pages`, `plan_plain_sends_when_view_empty`) — same shape: pure function, table-ish assertions, no async or teloxide. - **Deliberately not seamed: the debounce timing.** The deadline loop, reset-on-message, and single-flush-task lifecycle are inherently async and stateful; a meaningful test would require `tokio::time::pause/advance` and would largely assert tokio's own behaviour. Consistent with the existing untested async delivery loop (`deliver_streaming`), the timing stays unit-untested; only the pure `collate` helper is tested. ## Out of Scope - The HTTP API path (no splitting problem there). - The `4096-boundary heuristic` for perfectly reconstructing split pastes without an injected blank line — considered and rejected in ADR 0005; may be revisited if `\n\n`-join mangling of pasted code proves annoying in practice. - Making the debounce window user-configurable. - Any change to the abort/steer semantics, `pi`'s RPC steering, or the scheduled/silent delivery path. - Fixing the pre-existing `CONTEXT.md` "Command" entry contradiction (unknown commands are replied to, not forwarded to pi) — surfaced during grilling, filed as a separate follow-up issue. ## Further Notes - Recorded in `ADR 0005 — Debounce Telegram messages into a single turn` (`docs/adr/0005-telegram-message-burst-debounce.md`), which captures the 500ms trailing-debounce decision, the `\n\n` join and its trade-off, the rejected 4096-boundary heuristic, and the accepted ~500ms latency on every message. - `CONTEXT.md` gained the **Message Burst** glossary term and its **Session** entry now references bursts. - Key consequence to keep in mind during review: a burst is defined *purely by arrival timing*. Two genuinely separate messages typed within 500ms of each other will be merged into one turn — this is intended and unavoidable given evie cannot distinguish them from a split paste.
weiwen 2026-07-06 23:23:09 +08:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
weiwen/evie#51
No description provided.