Skip to content

Commit 4934278

Browse files
fix: eager binding in from_json prevents cross-chat resolution (P1)
When deserializing via activate() or global singleton (no explicit chat= parameter), from_json now eagerly binds adapter and state from the currently active Chat. This prevents a thread/channel deserialized under chat_a from later resolving to chat_b when the context changes. Changes: - ThreadImpl.from_json: eagerly bind from get_chat_singleton() when no explicit chat= or adapter= is provided - ChannelImpl.from_json: same fix - 2 new tests: eagerly-bound thread survives context exit, and doesn't re-resolve to a different chat activated later - Fix loose cache assertion (<=2 → ==1) in Discord eviction test - Remove unused real_tests variable in fidelity script Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b865965 commit 4934278

5 files changed

Lines changed: 41 additions & 2 deletions

File tree

scripts/verify_test_fidelity.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,6 @@ def main() -> int:
210210
total_missing += len(missing)
211211
total_absorbers += absorbers
212212

213-
real_tests = matched - absorbers
214213
absorber_note = f" ({absorbers} absorbers)" if absorbers else ""
215214
status = "OK" if not missing else f"GAPS ({len(missing)})"
216215
print(f"\n{ts_rel}")

src/chat_sdk/channel.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
_is_async_iterable,
2222
_to_message,
2323
get_chat_singleton,
24+
has_chat_singleton,
2425
)
2526
from chat_sdk.types import (
2627
THREAD_STATE_TTL_MS,
@@ -404,6 +405,12 @@ def from_json(
404405
raise RuntimeError(f'Adapter "{channel._adapter_name}" not found in the provided Chat instance')
405406
channel._adapter = resolved
406407
channel._state_adapter_instance = chat.get_state()
408+
elif has_chat_singleton() and channel._adapter_name:
409+
active = get_chat_singleton()
410+
resolved = active.get_adapter(channel._adapter_name)
411+
if resolved is not None:
412+
channel._adapter = resolved
413+
channel._state_adapter_instance = active.get_state()
407414
return channel
408415

409416
@classmethod

src/chat_sdk/thread.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -740,6 +740,14 @@ def from_json(
740740
raise RuntimeError(f'Adapter "{thread._adapter_name}" not found in the provided Chat instance')
741741
thread._adapter = resolved
742742
thread._state_adapter_instance = chat.get_state()
743+
elif has_chat_singleton() and thread._adapter_name:
744+
# Eagerly bind from the active/global chat so the thread doesn't
745+
# lazily re-resolve later (which could hit a different chat).
746+
active = get_chat_singleton()
747+
resolved = active.get_adapter(thread._adapter_name)
748+
if resolved is not None:
749+
thread._adapter = resolved
750+
thread._state_adapter_instance = active.get_state()
743751
return thread
744752

745753
@classmethod

tests/test_chat_resolver.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,31 @@ def test_from_json_resolves_via_activate(self):
168168
assert thread.adapter is not None
169169
assert thread.adapter.name == "slack"
170170

171+
def test_from_json_eagerly_binds_so_survives_context_exit(self):
172+
"""Thread deserialized inside activate() keeps its binding after exit."""
173+
chat = _make_chat("slack")
174+
175+
with chat.activate():
176+
thread = ThreadImpl.from_json(_thread_json("slack"))
177+
channel = ChannelImpl.from_json(_channel_json("slack"))
178+
179+
# After context exit, thread/channel should still work
180+
# because adapter was eagerly bound during from_json
181+
assert thread.adapter.name == "slack"
182+
assert channel.adapter.name == "slack"
183+
184+
def test_from_json_eagerly_binds_prevents_wrong_chat(self):
185+
"""Thread deserialized under chat_a doesn't resolve to chat_b later."""
186+
chat_a = _make_chat("a_adapter")
187+
chat_b = _make_chat("b_adapter")
188+
189+
with chat_a.activate():
190+
thread = ThreadImpl.from_json(_thread_json("a_adapter"))
191+
192+
# Now activate a different chat — thread should NOT re-resolve
193+
with chat_b.activate():
194+
assert thread.adapter.name == "a_adapter" # still bound to chat_a
195+
171196

172197
class TestExplicitChatParameter:
173198
"""Explicit chat= parameter on from_json beats all fallbacks."""

tests/test_production_fixes.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -559,7 +559,7 @@ async def test_thread_parent_cache_eviction_on_overflow(self):
559559

560560
# After eviction, all 1001 expired entries should be purged,
561561
# leaving only the newly inserted one
562-
assert len(adapter._thread_parent_cache) <= 2
562+
assert len(adapter._thread_parent_cache) == 1
563563
assert "new-thread-channel" in adapter._thread_parent_cache
564564

565565

0 commit comments

Comments
 (0)