Skip to content

fix(telegram): prevent duplicate messages in DM streaming#38644

Closed
eveiljuice wants to merge 6 commits intoopenclaw:mainfrom
eveiljuice:fix/telegram-dm-duplicate-messages
Closed

fix(telegram): prevent duplicate messages in DM streaming#38644
eveiljuice wants to merge 6 commits intoopenclaw:mainfrom
eveiljuice:fix/telegram-dm-duplicate-messages

Conversation

@eveiljuice
Copy link
Copy Markdown

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() in pi-embedded-subscribe.ts 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 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.ts fails to finalize a preview via edit and falls back to sendPayload(), 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, RecentDeliveredEntry
  • src/agents/pi-embedded-subscribe.tsshouldSkipAssistantText() now checks cross-turn cache; pushAssistantText() records to cache

Fix 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 before stopDraftLane(), delete after successful sendPayload()

Tests

Added src/agents/pi-embedded-helpers/messaging-dedupe.test.ts covering:

  • Hash building and truncation
  • Cross-turn duplicate detection
  • TTL expiration
  • Whitespace/casing normalization
  • Cache eviction and capacity limits
  • Timestamp update on re-record

Related Issues

Closes #37702, #38365
Related: #38434, #33308, #33453, #33592, #37697, #30316

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
@openclaw-barnacle openclaw-barnacle bot added channel: telegram Channel integration: telegram agents Agent runtime and tooling size: M labels Mar 7, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 7, 2026

Greptile Summary

This 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 messaging-dedupe.ts (with bounded capacity and TTL) and cleanup of orphaned preview messages in the lane-delivery.ts fallback send path.

Key concerns found:

  • Cross-turn dedup cache not populated from emitBlockChunk (pi-embedded-subscribe.ts line 516–518): The new recordDeliveredText call was only added to pushAssistantText, but emitBlockChunk — the primary delivery path in Telegram DM block-chunked streaming — calls rememberAssistantText directly and never records to recentDeliveredTexts. This is a significant gap that would leave the regression fix ineffective for the most common streaming flow.
  • Same gap in finalizeAssistantTexts splice path (line 204): When reasoning mode consolidates the final text via splice, only rememberAssistantText is called, not recordDeliveredText.
  • Orphaned preview cleanup asymmetry in lane-delivery.ts (lines 440–448): The new preview deletion is guarded by delivered === true, while the existing consumeArchivedAnswerPreviewForFinal path deletes even on delivered === false (when deleteIfUnused !== false). The asymmetry may be intentional but is worth explicit documentation.

Confidence Score: 2/5

  • The PR partially fixes the duplicate-message regression but contains a significant gap that leaves the primary Telegram DM streaming path unprotected by the new dedup cache.
  • The core logic in messaging-dedupe.ts is correct and well-tested, and the lane-delivery cleanup is directionally right. However, emitBlockChunk — the main delivery path in block-chunked Telegram DM streaming — never calls recordDeliveredText, meaning post-compaction replays on that path will still bypass the new cross-turn cache. This gap could leave the original regression (Outbound Telegram dedup resets after context compaction — causes duplicate messages #37702) partially unresolved despite the fix landing.
  • src/agents/pi-embedded-subscribe.ts — both emitBlockChunk and the finalizeAssistantTexts splice path need recordDeliveredText calls to fully cover the cross-turn dedup.

Comments Outside Diff (2)

  1. src/agents/pi-embedded-subscribe.ts, line 516-518 (link)

    Cross-turn dedup cache not populated from emitBlockChunk

    emitBlockChunk calls rememberAssistantText directly (which only updates the same-turn state), but never calls recordDeliveredText. This means any text delivered through the block-chunking path is never added to state.recentDeliveredTexts, so cross-turn duplicates produced by context compaction would bypass the new dedup cache entirely.

    Since Telegram DM streaming uses block chunking as the primary delivery path, this is a gap in the fix that covers exactly the regression described in the PR.

    rememberAssistantText(chunk) should be followed by recordDeliveredText(chunk, state.recentDeliveredTexts), mirroring what pushAssistantText does:

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/agents/pi-embedded-subscribe.ts
    Line: 516-518
    
    Comment:
    **Cross-turn dedup cache not populated from `emitBlockChunk`**
    
    `emitBlockChunk` calls `rememberAssistantText` directly (which only updates the same-turn state), but never calls `recordDeliveredText`. This means any text delivered through the block-chunking path is never added to `state.recentDeliveredTexts`, so cross-turn duplicates produced by context compaction would bypass the new dedup cache entirely.
    
    Since Telegram DM streaming uses block chunking as the primary delivery path, this is a gap in the fix that covers exactly the regression described in the PR.
    
    `rememberAssistantText(chunk)` should be followed by `recordDeliveredText(chunk, state.recentDeliveredTexts)`, mirroring what `pushAssistantText` does:
    
    
    
    How can I resolve this? If you propose a fix, please make it concise.
  2. src/agents/pi-embedded-subscribe.ts, line 204 (link)

    rememberAssistantText splice path skips recordDeliveredText

    In finalizeAssistantTexts, when includeReasoning && !params.onBlockReply and there are already texts above the baseline, the consolidated final text is written into assistantTexts via splice and rememberAssistantText is called directly — without calling recordDeliveredText. If the consolidated final text is different from the intermediate chunks that were pushed earlier via pushAssistantText (which do record), the final text won't be in the cross-turn cache and can still be re-delivered as a duplicate after compaction.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/agents/pi-embedded-subscribe.ts
    Line: 204
    
    Comment:
    **`rememberAssistantText` splice path skips `recordDeliveredText`**
    
    In `finalizeAssistantTexts`, when `includeReasoning && !params.onBlockReply` and there are already texts above the baseline, the consolidated final text is written into `assistantTexts` via `splice` and `rememberAssistantText` is called directly — without calling `recordDeliveredText`. If the consolidated final text is different from the intermediate chunks that were pushed earlier via `pushAssistantText` (which do record), the final text won't be in the cross-turn cache and can still be re-delivered as a duplicate after compaction.
    
    
    
    How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: e5a48b9

Comment on lines +440 to +448
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)}`,
);
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +184 to +185
// Record in cross-turn cache so post-compaction replays are caught.
recordDeliveredText(text, state.recentDeliveredTexts);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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).
@eveiljuice
Copy link
Copy Markdown
Author

CI Status

All code-related checks pass: typecheck, lint, oxfmt, unit tests (Node + Bun + Windows).

The failing checks (secrets, build-artifacts, install-smoke) are pre-existing infrastructure issues unrelated to this PR:

  • secrets: The detect-secrets scan falls back to a full-repo scan for fork PRs (cannot resolve base commit in shallow clone). The ~200 findings are all in existing test files (credentials.test.ts, feishu/, slack/, etc.) — none in files modified by this PR. Note: main branch skips this check entirely (Skipping detect-secrets on main until the allowlist cleanup lands).
  • build-artifacts / install-smoke: These require repository secrets not available to fork PRs.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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).
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines 517 to 520
assistantTexts.push(chunk);
rememberAssistantText(chunk);
recordDeliveredText(chunk, state.recentDeliveredTexts);
if (!params.onBlockReply) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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 },
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@steipete
Copy link
Copy Markdown
Contributor

steipete commented Mar 8, 2026

Maintainer triage call: split before merge (keep open for now).

Current branch mixes two scopes:

  • Telegram lane-delivery duplicate cleanup
  • cross-turn embedded assistant-text dedupe behavior

Requested next step:

  1. split into focused PRs (Telegram lane path vs embedded dedupe policy)
  2. add targeted regression coverage for each path
  3. re-open merge review on the narrower PRs

@eveiljuice
Copy link
Copy Markdown
Author

Split into two focused PRs per maintainer request:

  1. Telegram lane-delivery orphaned preview cleanupfix(telegram): delete orphaned preview message after fallback send #39455
  2. Cross-turn embedded assistant-text dedup cachefix(telegram): add cross-turn dedup cache to prevent duplicate messages after context compaction #39456

Each PR has its own scope and targeted regression coverage. Leaving this PR open for reference until the new ones are reviewed.

@vincentkoc
Copy link
Copy Markdown
Member

Replaced with #39455 and #39456

@vincentkoc vincentkoc closed this Mar 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling channel: telegram Channel integration: telegram size: M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Outbound Telegram dedup resets after context compaction — causes duplicate messages

3 participants