Skip to content

Commit 30f6ce5

Browse files
committed
fix(serde): make idempotent rebind transactional on adapter-lookup failure
Codex P2 (both ThreadImpl and ChannelImpl): `from_json(existing_inst, chat=Y)` was invalidating caches BEFORE calling `chat.get_adapter(name)`. If the lookup failed and raised `RuntimeError`, the caller catching the exception was left with a partially-mutated instance — `_state_adapter_ instance`, `_channel_cache`, `_message_history`, `_recent_messages`, `_is_subscribed_context` all silently wiped, but `_adapter` unchanged. Subsequent operations on the thread would re-resolve state/channel via the singleton instead of the previously-bound chat. Fix: pre-flight the adapter lookup BEFORE invalidating caches. If the name doesn't resolve in the new chat, raise immediately with all original state intact. The binding block below repeats the lookup when it actually applies the result; that's a cheap duplicate in exchange for transactional semantics. Same fix in both `ThreadImpl.from_json` and `ChannelImpl.from_json`. 2 regression tests, each captures a snapshot of every cache-bearing attribute before the rebind, calls `from_json(instance, chat=bad_chat)` expecting RuntimeError, then asserts every snapshotted attribute is byte-identical on the catch side: - test_should_leave_thread_unchanged_when_chat_rebind_lookup_fails - test_should_leave_channel_unchanged_when_chat_rebind_lookup_fails 3468 tests pass. https://claude.ai/code/session_01XE1bMoQ5BjCvgi1iLGKKhG
1 parent c345ac3 commit 30f6ce5

4 files changed

Lines changed: 135 additions & 5 deletions

File tree

src/chat_sdk/channel.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -423,11 +423,26 @@ def from_json(
423423
"""
424424
if isinstance(data, ChannelImpl):
425425
channel = data
426-
# Invalidate caches derived from the previous binding so the
427-
# rebind block below resolves them fresh against the new
428-
# adapter/chat. Both `_state_adapter_instance` (old chat's
429-
# state backend) and `_message_history` (old chat's cache)
430-
# would route to the previous context otherwise.
426+
# Transactional rebind: if chat= is passed without adapter=,
427+
# we'll need to resolve the adapter from the new chat by name.
428+
# Pre-flight that lookup and raise (leaving the channel
429+
# unchanged) BEFORE invalidating caches, so a caller that
430+
# catches the RuntimeError isn't left with a partially-reset
431+
# instance. The binding block below repeats the lookup when
432+
# it actually applies the result (cheap).
433+
if chat is not None and adapter is None:
434+
_lookup_name = channel._adapter_name or (
435+
channel._adapter.name if channel._adapter is not None else None
436+
)
437+
if _lookup_name and chat.get_adapter(_lookup_name) is None:
438+
raise RuntimeError(
439+
f'Adapter "{_lookup_name}" not found in the provided Chat instance'
440+
)
441+
442+
# Validation passed — safe to invalidate.
443+
# Both `_state_adapter_instance` (old chat's state backend)
444+
# and `_message_history` (old chat's cache) would route to
445+
# the previous context otherwise.
431446
if adapter is not None or chat is not None:
432447
channel._state_adapter_instance = None
433448
channel._message_history = None

src/chat_sdk/thread.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -843,6 +843,24 @@ def from_json(
843843
# operations to the previous context — the classic split-
844844
# routing bug.
845845
#
846+
# Transactional rebind: if the caller passes only `chat=`, we
847+
# need to resolve the adapter from the new chat by name. If that
848+
# lookup fails, we must raise BEFORE mutating any cache, so a
849+
# caller that catches the RuntimeError is left with an
850+
# unchanged thread. Pre-flight the lookup here; the binding
851+
# block below repeats it (cheap dict access) when it actually
852+
# applies the result.
853+
if chat is not None and adapter is None:
854+
_lookup_name = thread._adapter_name or (
855+
thread._adapter.name if thread._adapter is not None else None
856+
)
857+
if _lookup_name and chat.get_adapter(_lookup_name) is None:
858+
raise RuntimeError(
859+
f'Adapter "{_lookup_name}" not found in the provided Chat instance'
860+
)
861+
862+
# Validation passed — safe to invalidate caches.
863+
#
846864
# Every attribute that embeds a reference to the old context
847865
# gets cleared here: `_state_adapter_instance` (old chat's
848866
# state backend), `_channel_cache` (old adapter's channel),

tests/test_channel_faithful.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,48 @@ def test_should_invalidate_state_cache_on_idempotent_rebind(self):
722722
assert rebound._state_adapter_instance is None
723723
assert rebound.adapter.name == "teams"
724724

725+
def test_should_leave_channel_unchanged_when_chat_rebind_lookup_fails(self):
726+
"""Transactional rebind: `from_json(existing_channel, chat=Y)` must
727+
raise BEFORE mutating any cache when the adapter lookup against
728+
the new chat fails. Callers catching the exception get their
729+
original channel back unchanged. Regression for a Codex P2."""
730+
from chat_sdk.chat import Chat, ChatConfig
731+
from chat_sdk.testing import create_mock_adapter as _create
732+
from chat_sdk.testing import create_mock_state as _create_state
733+
734+
slack_a = _create("slack")
735+
state_a = _create_state()
736+
chat_b = Chat(
737+
ChatConfig(
738+
user_name="bot-b",
739+
adapters={"teams": _create("teams")}, # no 'slack'
740+
state=_create_state(),
741+
)
742+
)
743+
744+
original = ChannelImpl(
745+
_ChannelImplConfigWithAdapter(
746+
id="C123",
747+
adapter=slack_a,
748+
state_adapter=state_a,
749+
)
750+
)
751+
snapshot = {
752+
"_adapter": original._adapter,
753+
"_adapter_name": original._adapter_name,
754+
"_state_adapter_instance": original._state_adapter_instance,
755+
"_message_history": original._message_history,
756+
}
757+
758+
with pytest.raises(RuntimeError, match='Adapter "slack" not found'):
759+
ChannelImpl.from_json(original, chat=chat_b)
760+
761+
for attr, before in snapshot.items():
762+
after = getattr(original, attr)
763+
assert after == before, f"{attr}: expected {before!r}, got {after!r}"
764+
assert original._adapter is snapshot["_adapter"]
765+
assert original._state_adapter_instance is snapshot["_state_adapter_instance"]
766+
725767
def test_should_rebind_adapter_on_idempotent_chat_rebind_for_direct_constructed_channel(self):
726768
"""When `from_json(existing_channel, chat=Y)` rebinds a channel that
727769
was constructed directly (so `_adapter_name` is None), the new

tests/test_serialization.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,61 @@ def test_should_reset_is_subscribed_context_on_rebind(self, mock_state):
665665
rebound = ThreadImpl.from_json(original, adapter=second_adapter)
666666
assert rebound._is_subscribed_context is False
667667

668+
def test_should_leave_thread_unchanged_when_chat_rebind_lookup_fails(self, mock_state):
669+
"""Transactional rebind: `from_json(existing_thread, chat=Y)` must
670+
either fully apply the rebind or leave the thread untouched. If
671+
`chat.get_adapter(name)` returns None, the RuntimeError must fire
672+
BEFORE any cache invalidation — callers that catch the exception
673+
should be able to keep using the thread with its original
674+
bindings intact. Regression for a Codex P2."""
675+
from chat_sdk.chat import Chat, ChatConfig
676+
from chat_sdk.testing import create_mock_adapter, create_mock_state, create_test_message
677+
678+
slack_a = create_mock_adapter("slack")
679+
state_a = create_mock_state()
680+
# Chat B has a different adapter name, so looking up "slack" will fail.
681+
chat_b = Chat(
682+
ChatConfig(
683+
user_name="bot-b",
684+
adapters={"teams": create_mock_adapter("teams")},
685+
state=create_mock_state(),
686+
)
687+
)
688+
689+
original = ThreadImpl(
690+
_ThreadImplConfig(
691+
id="slack:C123:1234.5678",
692+
adapter=slack_a,
693+
state_adapter=state_a,
694+
channel_id="C123",
695+
initial_message=create_test_message("m1", "hello"),
696+
is_subscribed_context=True,
697+
)
698+
)
699+
# Capture every state-bearing attribute before the rebind.
700+
snapshot = {
701+
"_adapter": original._adapter,
702+
"_adapter_name": original._adapter_name,
703+
"_state_adapter_instance": original._state_adapter_instance,
704+
"_channel_cache": original._channel_cache,
705+
"_message_history": original._message_history,
706+
"_recent_messages": list(original._recent_messages),
707+
"_is_subscribed_context": original._is_subscribed_context,
708+
}
709+
710+
with pytest.raises(RuntimeError, match='Adapter "slack" not found'):
711+
ThreadImpl.from_json(original, chat=chat_b)
712+
713+
# Every cached attribute must be exactly as it was before. A
714+
# non-transactional implementation would have nulled some of these
715+
# before the raise, making recovery unsafe.
716+
for attr, before in snapshot.items():
717+
after = getattr(original, attr)
718+
assert after == before, f"{attr}: expected {before!r}, got {after!r}"
719+
# Extra identity check for objects where equality might be loose.
720+
assert original._adapter is snapshot["_adapter"]
721+
assert original._state_adapter_instance is snapshot["_state_adapter_instance"]
722+
668723
def test_should_invalidate_recent_messages_and_message_history_on_rebind(self, mock_state):
669724
"""Two additional caches that carry previous-binding references must
670725
also drop on idempotent rebind: `_recent_messages` (populated from

0 commit comments

Comments
 (0)