Skip to content

Fidelity: callback-answer discipline, message-id/edit/stale lifecycle, Redis test storage (v0.3.0) - #1

Merged
0x216 merged 3 commits into
mainfrom
feat/callback-answer-discipline-and-message-lifecycle
Jul 18, 2026
Merged

Fidelity: callback-answer discipline, message-id/edit/stale lifecycle, Redis test storage (v0.3.0)#1
0x216 merged 3 commits into
mainfrom
feat/callback-answer-discipline-and-message-lifecycle

Conversation

@0x216

@0x216 0x216 commented Jul 18, 2026

Copy link
Copy Markdown
Owner

Why

teremok's promise is test green ⇒ works in production. Two bug classes recently slipped a fully-green MockBot suite and only surfaced on a live bot:

  • Eternal spinner — a callback handler returned without callback.answer(). Real Telegram leaves a loading spinner on the button for ~30s; MockBot auto-acked every callback, so the test passed.
  • "Screen out of date" / menu freshness — handlers that gate on callback.message.message_id == stored_menu_id (delete-and-resend menus) have a stale branch that a mock minting a fresh carrier id on every tap can't reach — and returning a new id from an edit (Telegram keeps the id) mismodels the stored id itself.

Both are pure fidelity gaps, the same kind v0.2.0's validation layer set out to close.

What's in this PR

1. Callback-answer discipline — kills the eternal-spinner class (opt-in: strict_answer=True)

  • A second AnswerCallbackQuery for the same id raises the genuine TelegramBadRequest the live API returns (query is too old … or query ID is invalid), via the same check_response route as validation.
  • A dispatched, handled callback the handler never answered fails the step with CallbackNotAnswered.
  • DispatchResult.assert_answered() (+ .answered / .callback_query_id) asserts a single step without global strictness; MockedSession.answered_callbacks records ids regardless of the flag.
  • Opt-in, default off so existing suites that dispatch un-answered callbacks stay green (see migration note). Catches the bug the ElfBot team had to hand-roll a _StrictAnswerSession for.

2. Message-id + edit + stale-message lifecycle — kills the menu-freshness class

  • Edits now keep their message_id (an inline-message edit returns True); a fresh send still gets a new id.
  • MockBot.sent_messages / last_message expose the on-screen message history, so a test taps a button on an earlier, now-stale message (MockCallbackQuery(message=old_msg, data=...)) and reaches the handler's "screen out of date" branch. Regression test walks: open menu → tap current → bot pushes a new screen → tap the old one → out-of-date branch fires.

3a. Redis-backed FSM storage for tests (optional redis extra)

  • fake_redis_storage() → a real aiogram RedisStorage over in-process fakeredis (same JSON serialization + key builder as prod, no server). Passed as MockBot(router, storage=...).
  • Surfaces FSM bugs MemoryStorage hides — e.g. stashing a non-JSON value in state data: green on memory, raises on Redis (test included).

3b. Concurrent-update driving — deferred to a follow-up issue

Reliable per-step request attribution under asyncio.gather needs a contextvar stamp threaded from feed_update into MockedSession.make_request; a naïve gather() would mis-slice requests[start:]. Design sketch is in docs/superpowers/specs/2026-07-18-…-design.md and the tracking issue, rather than ship a half-baked version.

Compatibility / migration

  • No breaking changes. strict_answer and storage are new keyword-only options defaulting to prior behaviour; DispatchResult gained two fields with defaults. Existing public API and all pre-existing tests unchanged.
  • Migration: opt in per-bot with mock_bot(router, strict_answer=True) (or assert per-step with result.assert_answered()). A future major version may flip the default.

Checks

  • Full suite green (130 passed, +19 new across test_callback_answer.py, test_message_lifecycle.py, test_redis_storage.py).
  • ruff check . clean, mypy src strict clean, coverage 96% (gate ≥90%).
  • Docs updated: README, CHANGELOG (0.3.0), quirks table (#17–20), design spec.

Note: the separate coverage-table CI leg is independently stale vs aiogram 3.30.0 (new *EphemeralMessage* methods + subscription update) — pre-existing on main, intentionally not regenerated here to keep this diff focused on the feature.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UxTypHUUDsvB6coXwEKyaX

…dis test storage (v0.3.0)

Close two production bug classes that slipped a green MockBot suite:

1. Eternal spinner: a callback handler that never calls callback.answer()
   auto-acked silently. New opt-in strict_answer=True fails the step with
   CallbackNotAnswered and makes a second answer to the same query a genuine
   TelegramBadRequest (answer-once). DispatchResult.assert_answered() +
   .answered/.callback_query_id expose it per-step; MockedSession tracks
   answered_callbacks.

2. "Screen out of date"/menu freshness: edits now KEEP their message_id
   (inline edits return True), and MockBot.sent_messages/last_message expose the
   on-screen history so a test can tap a button on an earlier, now-stale message
   and reach a handler's stale branch.

Also add fake_redis_storage() (optional `redis` extra) — a real aiogram
RedisStorage over in-process fakeredis, same serialization/key builder as prod —
passed via MockBot(router, storage=...), to surface FSM bugs MemoryStorage hides.

Concurrent-update driving (double-tap races) is deferred to a follow-up issue
with a contextvar-based design sketch (docs/superpowers/specs/...).

All additive/opt-in: existing public API and tests unchanged; strict_answer and
storage default to prior behaviour. Suite green, ruff + mypy strict clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UxTypHUUDsvB6coXwEKyaX
claude added 2 commits July 18, 2026 21:28
…10/3.11

coverage.py 7.11.1 regressed: under `coverage run -m pytest` the process
segfaults (SIGSEGV / exit 139) during interpreter-shutdown garbage
collection on CPython 3.10 and 3.11 - *after* the suite reports
"130 passed" - once the traced object graph includes the v0.3.0 redis
test's aiogram + fakeredis objects. Bisected: 7.11.0 clean, 7.11.1+ bad;
both the C and pure-python cores crash, so no COVERAGE_CORE setting avoids
it, and 3.12/3.13 are unaffected. Coverage numbers are identical on
7.11.0, so the >=90% gate is untouched. Widen the bound once upstream
ships a fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UxTypHUUDsvB6coXwEKyaX
aiogram 3.30.0 added the `subscription` update type and the
DeleteEphemeralMessage / EditEphemeralMessage* methods; the committed
table was generated against 3.29.1, so the coverage-table freshness gate
failed. Regenerate via scripts/gen_coverage.py (byte-identical on py3.11
and py3.12).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UxTypHUUDsvB6coXwEKyaX
@0x216
0x216 merged commit 109c72f into main Jul 18, 2026
10 checks passed
@0x216
0x216 deleted the feat/callback-answer-discipline-and-message-lifecycle branch July 18, 2026 21:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants