Skip to content

fix: preserve extras and annotations in _send_update()#670

Open
mattheworiordan wants to merge 1 commit intomainfrom
fix/preserve-extras-in-send-update
Open

fix: preserve extras and annotations in _send_update()#670
mattheworiordan wants to merge 1 commit intomainfrom
fix/preserve-extras-in-send-update

Conversation

@mattheworiordan
Copy link
Member

@mattheworiordan mattheworiordan commented Mar 6, 2026

Summary

  • Fixes a spec violation in _send_update() where extras and annotations were silently dropped when calling update_message(), delete_message(), or append_message() on both REST and Realtime channels.
  • The spec (RSL15b, RTL32b) requires "whatever fields were in the user-supplied Message" to be sent on the wire. The implementation was cherry-picking fields and omitting extras and annotations.
  • Bug was introduced in 1723f5d (REST, Jan 13 2026) and 0b93c10 (Realtime, Jan 15 2026).

Why this matters

This is essential functionality for AI Transport use cases. A key pattern for AI token streaming is signaling message completion via extras, e.g.:

await channel.update_message(Message(
    serial=serial,
    extras={"headers": {"status": "complete"}},
))

Without this fix, the extras field is silently dropped — subscribers never receive the completion signal. This blocks AI Transport demos and production patterns that rely on extras metadata on update/append/delete operations.

Changes

Fix (2 lines each):

  • ably/realtime/channel.py — Pass extras and annotations through in _send_update()
  • ably/rest/channel.py — Same fix

Tests:

  • 3 new unit tests (test/unit/mutable_message_test.py) — regression tests for extras/annotations serialization
  • 1 new REST integration test — end-to-end: publish → update with extras → verify extras in history
  • 1 new Realtime integration test — end-to-end: publish → update with extras → verify extras via subscription
  • All tests reference spec items (RSL15b, RTL32b, TM2i, TM2u)

Note: current update semantics are whole-message replacement

Today, update_message() replaces the entire message — there is no partial/field-level update support. If you send only extras, the data field will be whatever you provide (or empty). Similarly, append_message() only supports string concatenation of the entire data field.

For AI Transport use cases, more granular semantics would be valuable — e.g. appending to a specific field within a JSON data body, or updating extras without replacing data. Related tickets tracking these improvements:

  • AIT-532 — Support append() to a specific field in a message
  • AIT-477 — Define a mechanism to mark a message as completed when being appended to
  • AIT-505 — Well-known "end" header on message.append that can skip/close the appendRollupWindow

Test plan

  • uv run --with ruff ruff check — linter clean
  • uv run --extra dev python -m pytest test/unit/mutable_message_test.py — 11 passed
  • uv run --extra dev python -m pytest test/ably/rest/restchannelmutablemessages_test.py test/ably/realtime/realtimechannelmutablemessages_test.py -k "not encryption" — 42 passed

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Message updates now preserve extras and annotations metadata when modifying published messages in both realtime and REST channels, maintaining consistency across the complete message lifecycle.
  • Tests

    • Comprehensive test coverage added across realtime, REST, and unit tests to verify extras and annotations are correctly preserved during message updates and serialization.

The _send_update() method in both RestChannel and RealtimeChannel
reconstructed the Message object without copying extras or annotations
from the user-supplied message. This violated RSL15b/RTL32b which
require "whatever fields were in the user-supplied Message" to be
sent on the wire.

Bug was introduced in 1723f5d (REST) and 0b93c10 (Realtime).
@coderabbitai
Copy link

coderabbitai bot commented Mar 6, 2026

Walkthrough

This pull request adds support for preserving extras and annotations fields when updating messages in both realtime and REST channels, with corresponding integration and unit tests to verify the functionality.

Changes

Cohort / File(s) Summary
Channel Update Implementation
ably/realtime/channel.py, ably/rest/channel.py
Added extras and annotations field propagation in _send_update method to preserve these fields when constructing update messages.
Integration Tests
test/ably/realtime/realtimechannelmutablemessages_test.py, test/ably/rest/restchannelmutablemessages_test.py
Added new tests test_update_message_preserves_extras that verify extras are preserved through message update operations and MESSAGE_UPDATE events.
Unit Tests
test/unit/mutable_message_test.py
Added three unit tests verifying extras and annotations are correctly preserved in as_dict() output, and that extras=None is excluded from serialization.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes


🐰 ✨ A patch of extras and notes,
Preserved through updates, all afloat,
Messages dance with their fields intact,
Tests confirm each fact, on track! 🎉

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 68.75% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and concisely describes the main fix: preserving extras and annotations in the _send_update() method, which aligns with the core changes across all modified files.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/preserve-extras-in-send-update

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
test/unit/mutable_message_test.py (1)

137-152: This doesn’t cover the regression path for annotations.

The bug fixed in this PR was in Channel._send_update() reconstructing a new Message, while this test only re-checks Message.as_dict(). If annotations are dropped during update-message construction again, this test still passes. A focused update_message() regression test for annotations would close that gap.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/unit/mutable_message_test.py` around lines 137 - 152, Add a focused
regression test that exercises the Channel._send_update code path (not just
Message.as_dict) to ensure annotations survive message reconstruction: create a
Message with MessageAnnotations, invoke Channel._send_update (or the public
method that triggers it) using that Message, capture the reconstructed/returned
Message, and assert its annotations.summary['reaction:distinct.v1'] ==
{'thumbsup': 5}; reference Message, MessageAnnotations, and Channel._send_update
to locate the code to test.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/unit/mutable_message_test.py`:
- Around line 123-135: The test test_message_extras_none_excluded_from_as_dict
should assert the absence of the 'extras' key rather than allowing a present
None value; update the assertion in that test to explicitly check that 'extras'
is not in msg_dict (referencing the Message instance and its as_dict() output)
so the contract "excluded from output" is enforced for Message.as_dict.

---

Nitpick comments:
In `@test/unit/mutable_message_test.py`:
- Around line 137-152: Add a focused regression test that exercises the
Channel._send_update code path (not just Message.as_dict) to ensure annotations
survive message reconstruction: create a Message with MessageAnnotations, invoke
Channel._send_update (or the public method that triggers it) using that Message,
capture the reconstructed/returned Message, and assert its
annotations.summary['reaction:distinct.v1'] == {'thumbsup': 5}; reference
Message, MessageAnnotations, and Channel._send_update to locate the code to
test.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: afc79b3d-8a38-4e80-b28f-86bfc9a27edb

📥 Commits

Reviewing files that changed from the base of the PR and between 324a9f6 and ecd1dd4.

📒 Files selected for processing (5)
  • ably/realtime/channel.py
  • ably/rest/channel.py
  • test/ably/realtime/realtimechannelmutablemessages_test.py
  • test/ably/rest/restchannelmutablemessages_test.py
  • test/unit/mutable_message_test.py

Comment on lines +123 to +135
# RSL15b, RTL32b, TM2i
def test_message_extras_none_excluded_from_as_dict():
"""Test that extras=None does not appear in as_dict output."""
message = Message(
name='test',
data='data',
serial='abc123',
action=MessageAction.MESSAGE_UPDATE,
)

msg_dict = message.as_dict()
assert msg_dict.get('extras') is None

Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Assert key absence directly here.

msg_dict.get('extras') is None also passes if as_dict() regresses to emitting {"extras": None}. If the contract is “excluded from output”, this should check that the key is absent.

Suggested assertion
-    assert msg_dict.get('extras') is None
+    assert 'extras' not in msg_dict
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# RSL15b, RTL32b, TM2i
def test_message_extras_none_excluded_from_as_dict():
"""Test that extras=None does not appear in as_dict output."""
message = Message(
name='test',
data='data',
serial='abc123',
action=MessageAction.MESSAGE_UPDATE,
)
msg_dict = message.as_dict()
assert msg_dict.get('extras') is None
# RSL15b, RTL32b, TM2i
def test_message_extras_none_excluded_from_as_dict():
"""Test that extras=None does not appear in as_dict output."""
message = Message(
name='test',
data='data',
serial='abc123',
action=MessageAction.MESSAGE_UPDATE,
)
msg_dict = message.as_dict()
assert 'extras' not in msg_dict
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/unit/mutable_message_test.py` around lines 123 - 135, The test
test_message_extras_none_excluded_from_as_dict should assert the absence of the
'extras' key rather than allowing a present None value; update the assertion in
that test to explicitly check that 'extras' is not in msg_dict (referencing the
Message instance and its as_dict() output) so the contract "excluded from
output" is enforced for Message.as_dict.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant