Skip to content

feat(telegram): send fresh finals for stale preview streams (port openclaw#72038) - #16261

Merged
teknium1 merged 1 commit into
mainfrom
openclaw-port/telegram-fresh-finals
Apr 27, 2026
Merged

feat(telegram): send fresh finals for stale preview streams (port openclaw#72038)#16261
teknium1 merged 1 commit into
mainfrom
openclaw-port/telegram-fresh-finals

Conversation

@teknium1

Copy link
Copy Markdown
Contributor

Summary

Long-running Telegram responses now show a completion-time timestamp instead of a first-token timestamp.

Ported from openclaw/openclaw#72038.

Telegram's editMessageText preserves the original message date. When a streamed reply runs for 60+ seconds (reasoning models, long tool chains), the final edit keeps the preview's start time — users can't tell how long the task actually took, unlike WhatsApp where final delivery gets its own timestamp.

When a preview has been visible for ≥ streaming.fresh_final_after_seconds (default 60), the stream consumer now sends the completed reply as a fresh message and best-effort deletes the stale preview. Short previews still edit in place (existing fast path). Only Telegram opts in — Discord/Slack/Matrix ignore the setting.

Changes

File What
gateway/stream_consumer.py Track _message_created_ts on first-send; new _should_send_fresh_final + _try_fresh_final; check at finalize=True. Falls back to normal edit on any failure.
gateway/config.py StreamingConfig.fresh_final_after_seconds: float = 60.0 (0 = disabled).
gateway/platforms/base.py Optional delete_message(chat_id, message_id) -> bool default no-op False.
gateway/platforms/telegram.py delete_message implemented via _bot.delete_message.
gateway/run.py Two StreamConsumerConfig(...) sites wire fresh_final_after_seconds only when source.platform == Platform.TELEGRAM.
website/docs/user-guide/configuration.md Doc for the new knob.
tests/gateway/test_stream_consumer_fresh_final.py 15 new tests.

Validation

Result
New tests 15/15 passing
tests/gateway/test_stream_consumer.py 75/75 passing
tests/gateway/test_config.py passing
Telegram-related gateway tests (-k telegram) 328/328 passing
py_compile on all touched files clean

Source PR

openclaw/openclaw#72038 — credit @rubencu.

Architectural differences vs. OpenClaw

  • OpenClaw tracks visibleSinceMs on the draft-stream object and tees that through archived-preview records. Hermes tracks the equivalent _message_created_ts directly on the GatewayStreamConsumer instance, which is a closer fit for the Python stream-consumer architecture (there's no separate draft-stream abstraction — the consumer owns the message lifecycle for a single reply).
  • OpenClaw deletes the old preview unconditionally as part of cleanup. Hermes makes delete_message an optional base-adapter method (default False), so non-Telegram adapters don't have to implement it, and a missing implementation is a no-op rather than a runtime failure.
  • OpenClaw enables on Telegram by default. Hermes keeps the cap 0 on all platforms EXCEPT Telegram (enabled in both gateway/run.py wiring sites). Other platforms ignore the knob — their edit APIs either don't have the stale-timestamp problem (Discord, Slack) or don't support edits at all.
  • Hermes's fresh-send falls back to the normal edit path if the fresh send fails; OpenClaw returns early on failure. The fallback is safer given hermes's flood-control + adaptive-backoff path is more elaborate.

Scope boundary

This PR only affects the final edit in a single streamed segment. It does NOT change:

  • Tool-boundary fresh messages (already separate code path — _reset_segment_state)
  • Tool-progress messages (those are a different progress_msg_id tracked in gateway/run.py)
  • Short previews (< threshold) — still edit in place
  • Non-Telegram platforms — fresh_final_after_seconds clamped to 0 at wire-up time

Related OpenClaw PRs evaluated but NOT ported

…nclaw#72038)

Ports openclaw/openclaw#72038 to hermes-agent.

Telegram's `editMessageText` preserves the original message timestamp,
so a long-running streamed reply (reasoning models that take 60+ seconds
to finish) would keep the first-token timestamp even after completion.
Users can't tell how long a task actually took.

When a preview message has been visible for >= 60s (configurable via
`streaming.fresh_final_after_seconds`), finalize by sending a fresh
message instead of editing in place, then best-effort delete the stale
preview. Short previews still edit in place (the existing fast path).

Implementation notes adapted from OpenClaw's TypeScript original:
- `StreamConsumerConfig` gains `fresh_final_after_seconds` (default 0 =
  legacy edit-in-place). Gateway-level `StreamingConfig` defaults to 60.
- `GatewayStreamConsumer` tracks `_message_created_ts` at first-send and
  checks it in `_send_or_edit` on `finalize=True`. New helpers
  `_should_send_fresh_final` + `_try_fresh_final`.
- `BasePlatformAdapter` gains optional `delete_message(chat_id, message_id)`
  returning False by default. `TelegramAdapter` implements it via
  `_bot.delete_message`.
- `gateway/run.py` only enables fresh-final for `Platform.TELEGRAM`;
  other platforms ignore the setting (they don't have the stale-edit
  timestamp problem or edit-then-read works cheaply).
- Fallback to normal edit on any fresh-send failure — no user-visible
  regression if Telegram rate-limits a send or the message is gone.

Tests: 15 new cases in tests/gateway/test_stream_consumer_fresh_final.py
covering short/long previews, config plumbing, delete-support absent,
send-failure fallback, __no_edit__ sentinel safety, and StreamingConfig
round-trip.
@teknium1

Copy link
Copy Markdown
Contributor Author

Review — PR #16261 (port of openclaw#72038)

Ran full test suite against the worktree; read gateway/stream_consumer.py end-to-end; traced every _message_id reassignment to see where _message_created_ts could drift.

Tests: 123/123 pass (tests/gateway/test_stream_consumer_fresh_final.py + test_stream_consumer.py + test_config.py) with 4-worker CI-parity harness.

What I checked

Concern Verdict
Fresh-send fires at wrong time (intermediate edit, not finalize) Safe — gated on finalize=True at stream_consumer.py:890, covered by test_only_finalize_triggers_fresh_final
__no_edit__ sentinel interaction Safe — _should_send_fresh_final rejects both None and __no_edit__ at L767; fresh-send returning message_id=None sets __no_edit__ + clears ts at L820–821
Flood-control / adaptive-backoff interaction Fresh-final success path (L892–894) short-circuits before the edit-success block, so _flood_strikes = 0 reset and _last_edit_time update are skipped. Irrelevant for the finalize return, but the stream consumer instance keeps any inflated _current_edit_interval in memory. Low severity since finalize is terminal.
delete_message scope Correct. Grepped every platform adapter — only Telegram has a bot-delete API surfaced today; Discord/Slack/Matrix/WhatsApp/Feishu/DingTalk/etc. have nothing. Since run.py clamps the knob to 0 everywhere except Telegram (L9157–9165, L9855–9865), the base no-op default is sufficient.
Delete target authorship Always bot-authored — _message_id is only ever assigned from adapter.send(...) return values. No user-message ID can reach delete_message.
48h Telegram delete window Fresh-final fires on finalize + threshold ≥ 60s; well inside the window.
Oversized final (>4096 chars) via fresh-send Non-issue. TelegramAdapter.send (telegram.py:985) internally calls truncate_message and chunks; returns last chunk's message_id, which the consumer correctly adopts.
Negative fresh_final_after_seconds config Safe. float(... or 0.0) preserves negatives, but _should_send_fresh_final uses threshold <= 0 (L765).

Minor hygiene observations (non-blocking)

  1. _message_created_ts not mirrored on every _message_id reassignment. Drift points at stream_consumer.py:355, 380, 529, 650, 657, 665. Today each of these is shielded from _should_send_fresh_final either by (a) the _message_created_ts is None guard (when _send_new_chunk at L529 sets a fresh message ID without ever having set ts), or (b) the subsequent first-send branch at L958 that re-establishes ts. So no current execution path triggers a bug — but one future refactor that lets the segment-break/overflow path reach a finalize=True call without clearing first could silently use a stale timestamp from the previous preview. Cheap hardening: clear _message_created_ts alongside every _message_id = None and set it alongside every _message_id = <string> assignment in _send_new_chunk / fallback paths, same as _reset_segment_state already does.

  2. Failure breadcrumb for orphaned preview. TelegramAdapter.delete_message and _try_fresh_final's cleanup both log at debug level. If a transient NetworkError leaves a visible stale preview above the fresh final, users have no log trace at default levels to diagnose it. Suggest bumping the cleanup-failure log in _try_fresh_final (L806) to info or warning — the fresh send already succeeded so it's not spammy, and it's the only user-visible artifact.

  3. No integration test for the int() coercion in TelegramAdapter.delete_message (telegram.py:1226–1227). A non-numeric chat_id would ValueError and be swallowed by the bare except. Untested, but realistically chat_id is always numeric for Telegram.

Verdict

Ship. Scope is tight and correct (Telegram-only at wire-up), the fallback-to-edit path is sound, all tests pass, and the base-adapter hook shape (default no-op, returning bool) is consistent with other optional hooks. The one-liner _message_created_ts hygiene fix is worth doing as a small followup but doesn't block merge — no current path exploits it.

@alt-glitch alt-glitch added type/feature New feature or request P2 Medium — degraded but workaround exists comp/gateway Gateway runner, session dispatch, delivery platform/telegram Telegram bot adapter labels Apr 27, 2026
@teknium1
teknium1 merged commit b16f9d4 into main Apr 27, 2026
11 of 13 checks passed
@teknium1
teknium1 deleted the openclaw-port/telegram-fresh-finals branch April 27, 2026 00:26
donald131 pushed a commit to donald131/hermes-agent that referenced this pull request May 2, 2026
…nclaw#72038) (NousResearch#16261)

Ports openclaw/openclaw#72038 to hermes-agent.

Telegram's `editMessageText` preserves the original message timestamp,
so a long-running streamed reply (reasoning models that take 60+ seconds
to finish) would keep the first-token timestamp even after completion.
Users can't tell how long a task actually took.

When a preview message has been visible for >= 60s (configurable via
`streaming.fresh_final_after_seconds`), finalize by sending a fresh
message instead of editing in place, then best-effort delete the stale
preview. Short previews still edit in place (the existing fast path).

Implementation notes adapted from OpenClaw's TypeScript original:
- `StreamConsumerConfig` gains `fresh_final_after_seconds` (default 0 =
  legacy edit-in-place). Gateway-level `StreamingConfig` defaults to 60.
- `GatewayStreamConsumer` tracks `_message_created_ts` at first-send and
  checks it in `_send_or_edit` on `finalize=True`. New helpers
  `_should_send_fresh_final` + `_try_fresh_final`.
- `BasePlatformAdapter` gains optional `delete_message(chat_id, message_id)`
  returning False by default. `TelegramAdapter` implements it via
  `_bot.delete_message`.
- `gateway/run.py` only enables fresh-final for `Platform.TELEGRAM`;
  other platforms ignore the setting (they don't have the stale-edit
  timestamp problem or edit-then-read works cheaply).
- Fallback to normal edit on any fresh-send failure — no user-visible
  regression if Telegram rate-limits a send or the message is gone.

Tests: 15 new cases in tests/gateway/test_stream_consumer_fresh_final.py
covering short/long previews, config plumbing, delete-support absent,
send-failure fallback, __no_edit__ sentinel safety, and StreamingConfig
round-trip.

Co-authored-by: Hermes Agent <agent@nousresearch.com>
02356abc pushed a commit to 02356abc/hermes-agent that referenced this pull request May 14, 2026
…nclaw#72038) (NousResearch#16261)

Ports openclaw/openclaw#72038 to hermes-agent.

Telegram's `editMessageText` preserves the original message timestamp,
so a long-running streamed reply (reasoning models that take 60+ seconds
to finish) would keep the first-token timestamp even after completion.
Users can't tell how long a task actually took.

When a preview message has been visible for >= 60s (configurable via
`streaming.fresh_final_after_seconds`), finalize by sending a fresh
message instead of editing in place, then best-effort delete the stale
preview. Short previews still edit in place (the existing fast path).

Implementation notes adapted from OpenClaw's TypeScript original:
- `StreamConsumerConfig` gains `fresh_final_after_seconds` (default 0 =
  legacy edit-in-place). Gateway-level `StreamingConfig` defaults to 60.
- `GatewayStreamConsumer` tracks `_message_created_ts` at first-send and
  checks it in `_send_or_edit` on `finalize=True`. New helpers
  `_should_send_fresh_final` + `_try_fresh_final`.
- `BasePlatformAdapter` gains optional `delete_message(chat_id, message_id)`
  returning False by default. `TelegramAdapter` implements it via
  `_bot.delete_message`.
- `gateway/run.py` only enables fresh-final for `Platform.TELEGRAM`;
  other platforms ignore the setting (they don't have the stale-edit
  timestamp problem or edit-then-read works cheaply).
- Fallback to normal edit on any fresh-send failure — no user-visible
  regression if Telegram rate-limits a send or the message is gone.

Tests: 15 new cases in tests/gateway/test_stream_consumer_fresh_final.py
covering short/long previews, config plumbing, delete-support absent,
send-failure fallback, __no_edit__ sentinel safety, and StreamingConfig
round-trip.

Co-authored-by: Hermes Agent <agent@nousresearch.com>
dannyJ848 pushed a commit to dannyJ848/hermes-agent that referenced this pull request May 17, 2026
…nclaw#72038) (NousResearch#16261)

Ports openclaw/openclaw#72038 to hermes-agent.

Telegram's `editMessageText` preserves the original message timestamp,
so a long-running streamed reply (reasoning models that take 60+ seconds
to finish) would keep the first-token timestamp even after completion.
Users can't tell how long a task actually took.

When a preview message has been visible for >= 60s (configurable via
`streaming.fresh_final_after_seconds`), finalize by sending a fresh
message instead of editing in place, then best-effort delete the stale
preview. Short previews still edit in place (the existing fast path).

Implementation notes adapted from OpenClaw's TypeScript original:
- `StreamConsumerConfig` gains `fresh_final_after_seconds` (default 0 =
  legacy edit-in-place). Gateway-level `StreamingConfig` defaults to 60.
- `GatewayStreamConsumer` tracks `_message_created_ts` at first-send and
  checks it in `_send_or_edit` on `finalize=True`. New helpers
  `_should_send_fresh_final` + `_try_fresh_final`.
- `BasePlatformAdapter` gains optional `delete_message(chat_id, message_id)`
  returning False by default. `TelegramAdapter` implements it via
  `_bot.delete_message`.
- `gateway/run.py` only enables fresh-final for `Platform.TELEGRAM`;
  other platforms ignore the setting (they don't have the stale-edit
  timestamp problem or edit-then-read works cheaply).
- Fallback to normal edit on any fresh-send failure — no user-visible
  regression if Telegram rate-limits a send or the message is gone.

Tests: 15 new cases in tests/gateway/test_stream_consumer_fresh_final.py
covering short/long previews, config plumbing, delete-support absent,
send-failure fallback, __no_edit__ sentinel safety, and StreamingConfig
round-trip.

Co-authored-by: Hermes Agent <agent@nousresearch.com>
gweeteve pushed a commit to gweeteve/hermes-agent that referenced this pull request Jun 2, 2026
…nclaw#72038) (NousResearch#16261)

Ports openclaw/openclaw#72038 to hermes-agent.

Telegram's `editMessageText` preserves the original message timestamp,
so a long-running streamed reply (reasoning models that take 60+ seconds
to finish) would keep the first-token timestamp even after completion.
Users can't tell how long a task actually took.

When a preview message has been visible for >= 60s (configurable via
`streaming.fresh_final_after_seconds`), finalize by sending a fresh
message instead of editing in place, then best-effort delete the stale
preview. Short previews still edit in place (the existing fast path).

Implementation notes adapted from OpenClaw's TypeScript original:
- `StreamConsumerConfig` gains `fresh_final_after_seconds` (default 0 =
  legacy edit-in-place). Gateway-level `StreamingConfig` defaults to 60.
- `GatewayStreamConsumer` tracks `_message_created_ts` at first-send and
  checks it in `_send_or_edit` on `finalize=True`. New helpers
  `_should_send_fresh_final` + `_try_fresh_final`.
- `BasePlatformAdapter` gains optional `delete_message(chat_id, message_id)`
  returning False by default. `TelegramAdapter` implements it via
  `_bot.delete_message`.
- `gateway/run.py` only enables fresh-final for `Platform.TELEGRAM`;
  other platforms ignore the setting (they don't have the stale-edit
  timestamp problem or edit-then-read works cheaply).
- Fallback to normal edit on any fresh-send failure — no user-visible
  regression if Telegram rate-limits a send or the message is gone.

Tests: 15 new cases in tests/gateway/test_stream_consumer_fresh_final.py
covering short/long previews, config plumbing, delete-support absent,
send-failure fallback, __no_edit__ sentinel safety, and StreamingConfig
round-trip.

Co-authored-by: Hermes Agent <agent@nousresearch.com>
waefrebeorn pushed a commit to waefrebeorn/slermes that referenced this pull request Jul 2, 2026
…nclaw#72038) (NousResearch#16261)

Ports openclaw/openclaw#72038 to hermes-agent.

Telegram's `editMessageText` preserves the original message timestamp,
so a long-running streamed reply (reasoning models that take 60+ seconds
to finish) would keep the first-token timestamp even after completion.
Users can't tell how long a task actually took.

When a preview message has been visible for >= 60s (configurable via
`streaming.fresh_final_after_seconds`), finalize by sending a fresh
message instead of editing in place, then best-effort delete the stale
preview. Short previews still edit in place (the existing fast path).

Implementation notes adapted from OpenClaw's TypeScript original:
- `StreamConsumerConfig` gains `fresh_final_after_seconds` (default 0 =
  legacy edit-in-place). Gateway-level `StreamingConfig` defaults to 60.
- `GatewayStreamConsumer` tracks `_message_created_ts` at first-send and
  checks it in `_send_or_edit` on `finalize=True`. New helpers
  `_should_send_fresh_final` + `_try_fresh_final`.
- `BasePlatformAdapter` gains optional `delete_message(chat_id, message_id)`
  returning False by default. `TelegramAdapter` implements it via
  `_bot.delete_message`.
- `gateway/run.py` only enables fresh-final for `Platform.TELEGRAM`;
  other platforms ignore the setting (they don't have the stale-edit
  timestamp problem or edit-then-read works cheaply).
- Fallback to normal edit on any fresh-send failure — no user-visible
  regression if Telegram rate-limits a send or the message is gone.

Tests: 15 new cases in tests/gateway/test_stream_consumer_fresh_final.py
covering short/long previews, config plumbing, delete-support absent,
send-failure fallback, __no_edit__ sentinel safety, and StreamingConfig
round-trip.

Co-authored-by: Hermes Agent <agent@nousresearch.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists platform/telegram Telegram bot adapter type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants