fix: preserve extras and annotations in _send_update()#670
fix: preserve extras and annotations in _send_update()#670mattheworiordan wants to merge 1 commit intomainfrom
Conversation
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).
WalkthroughThis pull request adds support for preserving Changes
Estimated code review effort🎯 2 (Simple) | ⏱️ ~12 minutes
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Tip Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs). 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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
test/unit/mutable_message_test.py (1)
137-152: This doesn’t cover the regression path forannotations.The bug fixed in this PR was in
Channel._send_update()reconstructing a newMessage, while this test only re-checksMessage.as_dict(). Ifannotationsare dropped during update-message construction again, this test still passes. A focusedupdate_message()regression test forannotationswould 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
📒 Files selected for processing (5)
ably/realtime/channel.pyably/rest/channel.pytest/ably/realtime/realtimechannelmutablemessages_test.pytest/ably/rest/restchannelmutablemessages_test.pytest/unit/mutable_message_test.py
| # 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 | ||
|
|
There was a problem hiding this comment.
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.
| # 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.
Summary
_send_update()whereextrasandannotationswere silently dropped when callingupdate_message(),delete_message(), orappend_message()on both REST and Realtime channels.extrasandannotations.1723f5d(REST, Jan 13 2026) and0b93c10(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.:Without this fix, the
extrasfield is silently dropped — subscribers never receive the completion signal. This blocks AI Transport demos and production patterns that rely onextrasmetadata on update/append/delete operations.Changes
Fix (2 lines each):
ably/realtime/channel.py— Passextrasandannotationsthrough in_send_update()ably/rest/channel.py— Same fixTests:
test/unit/mutable_message_test.py) — regression tests for extras/annotations serializationNote: 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 onlyextras, thedatafield will be whatever you provide (or empty). Similarly,append_message()only supports string concatenation of the entiredatafield.For AI Transport use cases, more granular semantics would be valuable — e.g. appending to a specific field within a JSON
databody, or updatingextraswithout replacingdata. Related tickets tracking these improvements:append()to a specific field in a messagemessage.appendthat can skip/close the appendRollupWindowTest plan
uv run --with ruff ruff check— linter cleanuv run --extra dev python -m pytest test/unit/mutable_message_test.py— 11 passeduv 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
Tests