You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Three borrowed patterns from the ACP / Telegram bot status research doc. Targets the Telegram-side equivalent of the matrix.org M_LIMIT_EXCEEDED regression we fixed earlier — group chats hit Telegram's 20/min editMessageText cap on long turns and fragment the status frame into multiple bubbles.
Picked via isGroupChat(chatId) — Telegram convention: chat_id < 0 means group / supergroup / channel. Previously a single EDIT_MIN_INTERVAL_MS = 500 for everything.
2. Char-delta edit gate
n3d1117/chatgpt-telegram-bot pattern. On non-terminal renders with no phase transition, skip the edit if the rendered text length changed by fewer than CHAR_DELTA_CUTOFF = 50 chars. Stops thinking-preview ticks from burning quota on 5-char diffs. Phase transitions (e.g. STARTING → RUNNING) always edit through so the user sees the header emoji + label swap fast.
3. Self-tuning per-turn 429 backoff
Each 429 response from Telegram bumps backoffBonusMs by BACKOFF_PER_429_MS = 1500, capped at MAX_BACKOFF_BONUS_MS = 10_000. The bonus is added to the effective edit floor, so noisy turns naturally settle into a sustainable cadence. Wiped on /watch fresh-spawn so calm turns start fast.
Bonus: kill the fragmentation regression
Previously when editStatus failed it fell through to sendStatus, creating a new bubble per failed edit — same bug Matrix had pre-d79c798. Now editStatus returns a discriminated { ok, rateLimited?, retryAfterMs? } and a 429 result skips the sendStatus fallback, preserving the existing statusMessageId for the next allowed tick.
Telegram's 429 carries retry_after in seconds inside parameters.* — parsed and converted to ms; honored via rateLimitedUntil in DO storage the same way Matrix's bridge handles it.
Cadence tweak
Bumped TICKER_REFRESH_MS 1500 → 2500 in line with the OpenACP observation (research doc, line 27 of THINKING_REFRESH_MS = 15s) that the field-conservative norm is much slower than us. We're still more aggressive but no longer redlining the cap.
Test plan
bun run typecheck clean
bun test src/render.test.ts — 44/44 pass
bun test overall — 65/73 pass; the 8 failures under bun test are pre-existing vi.stubGlobal-under-bun mismatches per docs/agent-handoff.md, NOT introduced by this change
Deployed to TG bridge worker f94db34a
Live test: send a multi-step prompt in a TG group; expect one status frame that edits in place all the way to Done, never fragments
Live test: send a flurry of prompts in a TG group; expect the backoff bonus to ratchet up and cadence to slow naturally
What's NOT in this PR (deferred)
Telegram render.ts focus-window revert (still has commit 8728c39 code, per handoff). Out of scope here — touch poller only.
3-minute thinking auto-stop (the OpenACP THINKING_MAX_MS pattern). Would need render-side changes; held for a separate PR.
Lossless tool history (OpenACP's many-card model). That's a rewrite, not a tune.
Three-borrow PR from the ACP / Telegram bot status research in
docs/acp-telegram-status-research.md. Targets the per-room
M_LIMIT_EXCEEDED equivalent on the Telegram side — group rooms hit
the 20/min editMessageText cap on long turns, fragmenting the status
frame into multiple bubbles.
(1) Group vs private edit floor
EDIT_MIN_INTERVAL_MS_PRIVATE = 800 (Telegram ~1/sec per chat)
EDIT_MIN_INTERVAL_MS_GROUP = 3500 (Telegram 20/min → 3s, padded)
Picked via `isGroupChat(chatId)` — Telegram convention: chat_id < 0
means group / supergroup / channel.
(2) Char-delta edit gate (n3d1117/chatgpt-telegram-bot pattern)
On non-terminal renders with no phase transition, skip the edit if
the rendered text length changed by fewer than CHAR_DELTA_CUTOFF
(50) chars. Stops thinking-preview ticks from burning quota on
5-char diffs. Phase transitions (e.g. STARTING → RUNNING) always
edit through so the user sees the header emoji + label swap fast.
(3) Self-tuning per-turn 429 backoff
Each 429 response from Telegram bumps `backoffBonusMs` by
BACKOFF_PER_429_MS = 1500ms, capped at MAX_BACKOFF_BONUS_MS =
10_000. The bonus is added to the effective edit floor, so noisy
turns naturally settle into a sustainable cadence. Wiped on
/watch fresh-spawn so calm turns start fast.
Also stops the fragmentation regression on the TG side: previously
when editStatus failed it fell through to sendStatus, creating a new
bubble per failed edit (same bug Matrix had pre-d79c798). Now
editStatus returns a discriminated `{ ok, rateLimited?, retryAfterMs? }`
and a 429 result skips the sendStatus fallback, preserving the
existing statusMessageId for the next allowed tick.
Telegram's 429 carries retry_after in seconds inside parameters.* —
parsed and converted to ms; honored via rateLimitedUntil in DO storage
the same way Matrix's bridge handles it.
Bumped TICKER_REFRESH_MS 1500 → 2500 in line with the OpenACP
observation that 5s text-stream debounce + 15s thinking ticker is the
field-conservative norm. We're still much more aggressive than them
but no longer redlining the cap.
Tests: 65/65 pass on the paths this PR touches. The 8 failures under
`bun test` are pre-existing vi.stubGlobal-under-bun mismatches per
docs/agent-handoff.md — not introduced by this change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
Capy auto-review is paused for this organization because the monthly auto-review limit has been reached. Increase the limit or turn it off in billing settings to resume automatic reviews.
The reason will be displayed to describe this comment to others. Learn more.
🔴 New per-turn state keys not cleaned up on fresh-spawn, causing stale backoff to leak into subsequent turns
The handleWatch fresh-spawn path at replicas-telegram-bridge/src/poller.ts:200-208 selectively deletes specific storage keys before seeding a new turn, but does NOT include the four new keys introduced by this PR: backoffBonusMs, rateLimitedUntil, lastRenderedLen, and lastEditedPhase. The comment on BACKOFF_PER_429_MS (line 83) explicitly states these "Wipe when the DO state resets on the next /watch fresh-spawn, so it's per-turn — noisy turns settle into a sustainable cadence, calm turns start fast." However this invariant is violated. When a follow-up message arrives within the 30s pendingCleanup window (before deleteAll() fires), the steering gate rejects it (pendingCleanup=true) and falls through to fresh-spawn — inheriting up to 10,000ms of backoffBonusMs from the previous rate-limited turn. This causes the new turn's status edits to be throttled far more aggressively than intended, degrading the live-edit streaming experience.
(Refers to lines 200-208)
Was this helpful? React with 👍 or 👎 to provide feedback.
The reason will be displayed to describe this comment to others. Learn more.
🔴 New per-turn state keys not cleaned up on fresh-spawn, causing stale backoff to leak into subsequent turns
The handleWatch fresh-spawn path at replicas-telegram-bridge/src/poller.ts:200-208 selectively deletes specific storage keys before seeding a new turn, but does NOT include the four new keys introduced by this PR: backoffBonusMs, rateLimitedUntil, lastRenderedLen, and lastEditedPhase. The comment on BACKOFF_PER_429_MS (line 83) explicitly states these "Wipe when the DO state resets on the next /watch fresh-spawn, so it's per-turn — noisy turns settle into a sustainable cadence, calm turns start fast." However this invariant is violated. When a follow-up message arrives within the 30s pendingCleanup window (before deleteAll() fires), the steering gate rejects it (pendingCleanup=true) and falls through to fresh-spawn — inheriting up to 10,000ms of backoffBonusMs from the previous rate-limited turn. This causes the new turn's status edits to be throttled far more aggressively than intended, degrading the live-edit streaming experience.
(Refers to lines 200-208)
Was this helpful? React with 👍 or 👎 to provide feedback.
@devin-ai-integration[bot] This repository is not connected to a Replicas organization.
The char-delta gate at line 633 skips edits when Math.abs(text.length - lastRenderedLen) < CHAR_DELTA_CUTOFF (50 chars) and there's no phase transition. Elapsed-time ticker updates (e.g., "🤔 Starting · 3s" → "🤔 Starting · 5s") change the text by 0–2 characters, so they are ALWAYS blocked by this gate within the same phase. This directly defeats the stated purpose of TICKER_REFRESH_MS (line 71-73: "refresh the rendered status... so the ⏱ ticker stays live"). In practice, the user sees a frozen timer during the STARTING/PLANNING phases until the first tool call arrives and adds 50+ chars of content. The alarmInner code at line 542-544 still triggers renderAndSend() when tickerStale is true, but the edit never reaches Telegram.
Prompt for agents
The char-delta gate at line 633 in renderAndSend() blocks ticker-only updates because elapsed time changes (e.g. '3s' to '5s') alter the text by only 0-2 characters, well under CHAR_DELTA_CUTOFF=50. This conflicts with the TICKER_REFRESH_MS mechanism (line 71-73, 542-544) which explicitly exists to keep the elapsed timer live.
Possible approaches:
1. Add a 'tickerStale' boolean parameter to renderAndSend() and bypass gate (3) when the caller is the ticker-stale path. This preserves the char-delta gate's intent (blocking trivial thinking-preview updates) while allowing ticker refreshes.
2. Track last-successful-edit time separately from last-attempted-edit time, and only apply the char-delta gate when the last successful edit was recent (< TICKER_REFRESH_MS ago). If it's been > TICKER_REFRESH_MS since the last successful edit, bypass gate (3).
3. Compare the actual text content (not just length) to determine if the change is ticker-only vs thinking-preview. But this is complex and fragile.
Approach (1) seems cleanest — add a parameter like `bypassCharDelta?: boolean` defaulting to false, and pass true from the tickerStale callsite in alarmInner.
Was this helpful? React with 👍 or 👎 to provide feedback.
The char-delta gate at line 633 skips edits when Math.abs(text.length - lastRenderedLen) < CHAR_DELTA_CUTOFF (50 chars) and there's no phase transition. Elapsed-time ticker updates (e.g., "🤔 Starting · 3s" → "🤔 Starting · 5s") change the text by 0–2 characters, so they are ALWAYS blocked by this gate within the same phase. This directly defeats the stated purpose of TICKER_REFRESH_MS (line 71-73: "refresh the rendered status... so the ⏱ ticker stays live"). In practice, the user sees a frozen timer during the STARTING/PLANNING phases until the first tool call arrives and adds 50+ chars of content. The alarmInner code at line 542-544 still triggers renderAndSend() when tickerStale is true, but the edit never reaches Telegram.
Prompt for agents
The char-delta gate at line 633 in renderAndSend() blocks ticker-only updates because elapsed time changes (e.g. '3s' to '5s') alter the text by only 0-2 characters, well under CHAR_DELTA_CUTOFF=50. This conflicts with the TICKER_REFRESH_MS mechanism (line 71-73, 542-544) which explicitly exists to keep the elapsed timer live.
Possible approaches:
1. Add a 'tickerStale' boolean parameter to renderAndSend() and bypass gate (3) when the caller is the ticker-stale path. This preserves the char-delta gate's intent (blocking trivial thinking-preview updates) while allowing ticker refreshes.
2. Track last-successful-edit time separately from last-attempted-edit time, and only apply the char-delta gate when the last successful edit was recent (< TICKER_REFRESH_MS ago). If it's been > TICKER_REFRESH_MS since the last successful edit, bypass gate (3).
3. Compare the actual text content (not just length) to determine if the change is ticker-only vs thinking-preview. But this is complex and fragile.
Approach (1) seems cleanest — add a parameter like `bypassCharDelta?: boolean` defaulting to false, and pass true from the tickerStale callsite in alarmInner.
Was this helpful? React with 👍 or 👎 to provide feedback.
@devin-ai-integration[bot] This repository is not connected to a Replicas organization.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Three borrowed patterns from the ACP / Telegram bot status research doc. Targets the Telegram-side equivalent of the matrix.org
M_LIMIT_EXCEEDEDregression we fixed earlier — group chats hit Telegram's 20/mineditMessageTextcap on long turns and fragment the status frame into multiple bubbles.What it does
1. Group vs private edit floor
Picked via
isGroupChat(chatId)— Telegram convention:chat_id < 0means group / supergroup / channel. Previously a singleEDIT_MIN_INTERVAL_MS = 500for everything.2. Char-delta edit gate
n3d1117/chatgpt-telegram-bot pattern. On non-terminal renders with no phase transition, skip the edit if the rendered text length changed by fewer than
CHAR_DELTA_CUTOFF = 50chars. Stops thinking-preview ticks from burning quota on 5-char diffs. Phase transitions (e.g. STARTING → RUNNING) always edit through so the user sees the header emoji + label swap fast.3. Self-tuning per-turn 429 backoff
Each
429response from Telegram bumpsbackoffBonusMsbyBACKOFF_PER_429_MS = 1500, capped atMAX_BACKOFF_BONUS_MS = 10_000. The bonus is added to the effective edit floor, so noisy turns naturally settle into a sustainable cadence. Wiped on/watchfresh-spawn so calm turns start fast.Bonus: kill the fragmentation regression
Previously when
editStatusfailed it fell through tosendStatus, creating a new bubble per failed edit — same bug Matrix had pre-d79c798. NoweditStatusreturns a discriminated{ ok, rateLimited?, retryAfterMs? }and a429result skips thesendStatusfallback, preserving the existingstatusMessageIdfor the next allowed tick.Telegram's
429carriesretry_afterin seconds insideparameters.*— parsed and converted to ms; honored viarateLimitedUntilin DO storage the same way Matrix's bridge handles it.Cadence tweak
Bumped
TICKER_REFRESH_MS1500 → 2500 in line with the OpenACP observation (research doc, line 27 ofTHINKING_REFRESH_MS = 15s) that the field-conservative norm is much slower than us. We're still more aggressive but no longer redlining the cap.Test plan
bun run typecheckcleanbun test src/render.test.ts— 44/44 passbun testoverall — 65/73 pass; the 8 failures underbun testare pre-existingvi.stubGlobal-under-bun mismatches perdocs/agent-handoff.md, NOT introduced by this changef94db34aWhat's NOT in this PR (deferred)
8728c39code, per handoff). Out of scope here — touch poller only.THINKING_MAX_MSpattern). Would need render-side changes; held for a separate PR.Workspace · Slack Thread