fix(telegram): prevent duplicate messages in DM streaming#38644
fix(telegram): prevent duplicate messages in DM streaming#38644eveiljuice wants to merge 6 commits intoopenclaw:mainfrom
Conversation
Two fixes for duplicate message delivery in Telegram DMs: 1. Cross-turn dedup after context compaction (#37702) The existing shouldSkipAssistantText() only deduplicates within the same assistantMessageIndex. After context compaction the index resets, so when the model re-emits the same text from the compaction summary it gets delivered again as a new message. Added a rolling hash cache (recentDeliveredTexts) that persists across assistant turns within a session. Before delivering, the first 200 normalized characters of the text are compared against recently delivered hashes (max 20 entries, 1h TTL). Matches are skipped. 2. Orphaned preview cleanup on fallback send (#38365) When the answer lane falls back from preview finalization to a standard sendPayload(), the previously-flushed preview message was left visible, causing users to see both the preview and the final message (classic "message appears twice, then one disappears" behavior). Now the fallback send path captures the preview message id before stopping the draft lane and deletes it after a successful sendPayload(), preventing the duplicate from ever being visible. Closes #37702, #38365 Related: #38434, #33308, #33453
Greptile SummaryThis PR addresses a regression in Telegram DM streaming that caused users to see duplicate messages. It introduces two targeted fixes: a rolling cross-turn deduplication cache in Key concerns found:
Confidence Score: 2/5
|
| if (delivered && typeof previewMessageIdAfterStop === "number") { | ||
| try { | ||
| await params.deletePreviewMessage(previewMessageIdAfterStop); | ||
| } catch (err) { | ||
| params.log( | ||
| `telegram: ${laneName} fallback send orphaned preview cleanup failed (${previewMessageIdAfterStop}): ${String(err)}`, | ||
| ); | ||
| } | ||
| } |
There was a problem hiding this comment.
Orphaned preview not deleted when sendPayload returns false
The preview cleanup is guarded by if (delivered && ...), so when sendPayload returns false (skipped), the orphaned preview stays visible. If "skipped" means the message was intentionally suppressed, this is fine — but if it means the send silently failed for another reason, the user will see only the orphaned preview with no corresponding final message and no cleanup.
For consistency, note that the existing consumeArchivedAnswerPreviewForFinal path deletes the archived preview even when delivered is false (conditioned on deleteIfUnused !== false), suggesting the intent there was to clean up regardless of delivery success. It may be worth aligning or adding a comment explaining the deliberate asymmetry.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/telegram/lane-delivery.ts
Line: 440-448
Comment:
**Orphaned preview not deleted when `sendPayload` returns `false`**
The preview cleanup is guarded by `if (delivered && ...)`, so when `sendPayload` returns `false` (skipped), the orphaned preview stays visible. If "skipped" means the message was intentionally suppressed, this is fine — but if it means the send silently failed for another reason, the user will see only the orphaned preview with no corresponding final message and no cleanup.
For consistency, note that the existing `consumeArchivedAnswerPreviewForFinal` path deletes the archived preview even when `delivered` is `false` (conditioned on `deleteIfUnused !== false`), suggesting the intent there was to clean up regardless of delivery success. It may be worth aligning or adding a comment explaining the deliberate asymmetry.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e5a48b972e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // Record in cross-turn cache so post-compaction replays are caught. | ||
| recordDeliveredText(text, state.recentDeliveredTexts); |
There was a problem hiding this comment.
Record streamed assistant chunks in dedupe cache
The cross-turn dedupe cache is only populated in pushAssistantText, but Telegram DM streaming with onBlockReply appends text via emitBlockChunk (assistantTexts.push(...)) and bypasses this function. In that common path, recentDeliveredTexts remains empty, so isRecentlyDelivered(...) never matches and compaction-replayed text is still emitted as a duplicate.
Useful? React with 👍 / 👎.
…exts Address Greptile review: the cross-turn dedup cache was only populated via pushAssistantText but two other delivery paths — emitBlockChunk (primary Telegram DM block-chunked streaming path) and the finalizeAssistantTexts splice path (reasoning mode consolidation) — called rememberAssistantText directly without recording to the cross-turn cache. Also document the intentional asymmetry in lane-delivery orphaned preview cleanup (only on successful fallback send).
CI StatusAll code-related checks pass: typecheck, lint, oxfmt, unit tests (Node + Bun + Windows). The failing checks (
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 18c3588f74
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| */ | ||
| export function buildDeliveredTextHash(text: string): string { | ||
| const normalized = normalizeTextForComparison(text); | ||
| return normalized.slice(0, 200); |
There was a problem hiding this comment.
Verify full text after delivered-hash prefix match
buildDeliveredTextHash truncates to the first 200 normalized characters, so any two responses longer than 200 chars that share the same opening prefix are treated as identical for 1 hour. In compaction/retry flows this can suppress a legitimately updated assistant reply (different tail, same intro) because isRecentlyDelivered only compares the truncated hash; users then miss the second response entirely. Add a full-text check (or a collision-resistant hash of the full normalized text) before skipping delivery.
Useful? React with 👍 / 👎.
Address Codex P2 review: buildDeliveredTextHash previously used only the first 200 normalised chars, which could cause false positives when two different responses share the same opening paragraph but diverge later. Now uses prefix + full text length + FNV-1a-inspired 53-bit hash of the entire normalised string. Short texts (≤200 chars) are compared verbatim. This eliminates false positive collisions while keeping the comparison fast (no crypto overhead).
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8a586ae766
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| assistantTexts.push(chunk); | ||
| rememberAssistantText(chunk); | ||
| recordDeliveredText(chunk, state.recentDeliveredTexts); | ||
| if (!params.onBlockReply) { |
There was a problem hiding this comment.
Record dedupe entries only after block reply succeeds
recordDeliveredText is called before onBlockReply is executed, so a chunk is marked as “recently delivered” even when the send fails. In this file, emitBlockReplySafely catches and logs callback errors, which means transient Telegram/API failures can leave the user with no message while retries/compaction replays of the same chunk are then suppressed by isRecentlyDelivered(...). This turns a recoverable send failure into a dropped response.
Useful? React with 👍 / 👎.
Address Codex P2 review: recordDeliveredText was called before onBlockReply, so a transient send failure would mark the chunk as 'delivered' in the cross-turn cache, causing retries/compaction replays to be incorrectly suppressed — turning a recoverable failure into a dropped response. Now recordDeliveredText is called in the .then() success path of emitBlockReplySafely, ensuring only actually-delivered text enters the cache. The no-callback path (synchronous accumulation for final delivery) still records immediately since there's no async send.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 553adda45d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| replyToTag, | ||
| replyToCurrent, | ||
| }, | ||
| { sourceText: chunk }, |
There was a problem hiding this comment.
Deduplicate only finalized replies, not individual stream chunks
Passing sourceText: chunk here stores each streamed chunk in the cross-turn cache, while shouldSkipAssistantText now checks every future chunk against that cache. In text-end streaming, two different answers that share a common chunk (for example a repeated intro sentence) within the TTL will have that later chunk suppressed even though the overall replies differ, which yields truncated/missing output rather than true duplicate suppression. Consider hashing/checking finalized assistant message text instead of per-chunk text.
Useful? React with 👍 / 👎.
|
Maintainer triage call: split before merge (keep open for now). Current branch mixes two scopes:
Requested next step:
|
|
Split into two focused PRs per maintainer request:
Each PR has its own scope and targeted regression coverage. Leaving this PR open for reference until the new ones are reviewed. |
Problem
Telegram DM users see duplicate messages: the same reply appears twice, then one copy may disappear after ~20 seconds. This is a known regression in v2026.3.2 affecting multiple users (#37702, #38365, #38434, #33308, #33453).
Root causes identified:
1. Cross-turn dedup resets after context compaction (#37702)
shouldSkipAssistantText()inpi-embedded-subscribe.tsonly deduplicates within the sameassistantMessageIndex. After context compaction, the index resets, so when the model re-emits the same text from the compaction summary, it bypasses the dedup check and gets delivered as a new Telegram message.2. Orphaned preview on fallback send (#38365)
When the answer lane in
lane-delivery.tsfails to finalize a preview via edit and falls back tosendPayload(), the previously-flushed preview message is left visible. The user sees both the preview and the final message — the classic "message appears twice, one disappears" behavior.Solution
Fix 1: Rolling hash cache for cross-turn dedup
Added
recentDeliveredTexts— a bounded rolling cache (max 20 entries, 1h TTL) that persists across assistant message turns within a session. Before delivering text, the first 200 normalized characters are compared against recently delivered hashes. Matches are skipped.Files changed:
src/agents/pi-embedded-helpers/messaging-dedupe.ts— new exports:buildDeliveredTextHash,isRecentlyDelivered,recordDeliveredText,RecentDeliveredEntrysrc/agents/pi-embedded-subscribe.ts—shouldSkipAssistantText()now checks cross-turn cache;pushAssistantText()records to cacheFix 2: Delete orphaned preview after fallback send
The fallback send path now captures the preview message ID before stopping the draft lane and deletes it after successful delivery.
Files changed:
src/telegram/lane-delivery.ts— capture preview ID beforestopDraftLane(), delete after successfulsendPayload()Tests
Added
src/agents/pi-embedded-helpers/messaging-dedupe.test.tscovering:Related Issues
Closes #37702, #38365
Related: #38434, #33308, #33453, #33592, #37697, #30316