Skip to content

Commit 7dbfc29

Browse files
feat: 3-level Chat resolver (ContextVar → global → error)
Replace the process-global singleton with a 3-level resolution: 1. ContextVar for current async context (via chat.activate()) 2. Process-global default (via chat.register_singleton()) 3. RuntimeError if neither set This gives Python-friendly async isolation without breaking upstream TS parity. Existing code using register_singleton() is unchanged. Changes: - thread.py: _active_chat ContextVar + updated get_chat_singleton() - chat.py: Chat.activate() context manager returns _ChatActivation - ThreadImpl.from_json() and ChannelImpl.from_json() accept optional chat= parameter for explicit resolution without singleton lookup Usage: # Global (existing pattern, still works) chat.register_singleton() # Context-local (new, preferred for tests/multi-chat) with chat.activate(): thread = ThreadImpl.from_json(data) # Explicit (new, most specific) thread = ThreadImpl.from_json(data, chat=chat) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 40189b2 commit 7dbfc29

3 files changed

Lines changed: 94 additions & 15 deletions

File tree

src/chat_sdk/channel.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -373,11 +373,18 @@ def from_json(
373373
cls,
374374
data: dict[str, Any],
375375
adapter: Adapter | None = None,
376+
chat: Any = None,
376377
) -> ChannelImpl:
377378
"""Reconstruct a ChannelImpl from serialized JSON data.
378379
379-
Accepts both camelCase (canonical output of ``to_json()``) and
380-
snake_case keys for backward compatibility.
380+
Parameters
381+
----------
382+
data:
383+
Serialized channel dict (camelCase or snake_case keys accepted).
384+
adapter:
385+
Explicit adapter. Skips singleton lookup.
386+
chat:
387+
Explicit Chat instance for adapter/state resolution.
381388
"""
382389
channel = cls(
383390
_ChannelImplConfigLazy(
@@ -389,6 +396,11 @@ def from_json(
389396
)
390397
if adapter is not None:
391398
channel._adapter = adapter
399+
elif chat is not None:
400+
adapter_name = data.get("adapterName") or data.get("adapter_name", "")
401+
if adapter_name:
402+
channel._adapter = chat.get_adapter(adapter_name)
403+
channel._state_adapter_instance = chat.get_state()
392404
return channel
393405

394406
@classmethod

src/chat_sdk/chat.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from __future__ import annotations
1010

1111
import asyncio
12+
import contextvars
1213
import dataclasses
1314
import re
1415
import uuid
@@ -22,6 +23,7 @@
2223
from chat_sdk.logger import ConsoleLogger, Logger
2324
from chat_sdk.thread import (
2425
ThreadImpl,
26+
_active_chat,
2527
_ThreadImplConfig,
2628
get_chat_singleton,
2729
has_chat_singleton,
@@ -197,6 +199,28 @@ def _create_task(
197199
return None
198200

199201

202+
# ---------------------------------------------------------------------------
203+
# Chat activation context manager
204+
# ---------------------------------------------------------------------------
205+
206+
207+
class _ChatActivation:
208+
"""Context manager that sets a Chat as the active instance for the current context."""
209+
210+
def __init__(self, chat: Chat) -> None:
211+
self._chat = chat
212+
self._token: contextvars.Token[Any] | None = None
213+
214+
def __enter__(self) -> Chat:
215+
self._token = _active_chat.set(self._chat) # type: ignore[arg-type]
216+
return self._chat
217+
218+
def __exit__(self, *_: Any) -> None:
219+
if self._token is not None:
220+
_active_chat.reset(self._token)
221+
self._token = None
222+
223+
200224
# ---------------------------------------------------------------------------
201225
# Chat class
202226
# ---------------------------------------------------------------------------
@@ -339,6 +363,22 @@ def get_singleton() -> Chat:
339363
def has_singleton() -> bool:
340364
return has_chat_singleton()
341365

366+
def activate(self) -> _ChatActivation:
367+
"""Set this Chat as the active instance for the current async context.
368+
369+
Usage::
370+
371+
with chat.activate():
372+
# Thread/Channel deserialization resolves to this chat
373+
thread = ThreadImpl.from_json(data)
374+
375+
This is preferred over ``register_singleton()`` when multiple Chat
376+
instances coexist (e.g., in tests, multi-tenant servers). The
377+
activation is scoped to the current :class:`contextvars.Context`,
378+
so concurrent async tasks don't interfere.
379+
"""
380+
return _ChatActivation(self)
381+
342382
# ========================================================================
343383
# ChatInstance protocol implementation
344384
# ========================================================================

src/chat_sdk/thread.py

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from __future__ import annotations
99

1010
import asyncio
11+
import contextvars
1112
from collections.abc import AsyncIterator
1213
from dataclasses import dataclass
1314
from datetime import datetime, timezone
@@ -47,10 +48,11 @@
4748

4849

4950
# ---------------------------------------------------------------------------
50-
# Singleton access (mirrors chat-singleton.ts)
51+
# Chat resolver: ContextVar → process-global → error
5152
# ---------------------------------------------------------------------------
5253

53-
_singleton: _ChatSingleton | None = None
54+
_default_chat: _ChatSingleton | None = None
55+
_active_chat: contextvars.ContextVar[_ChatSingleton | None] = contextvars.ContextVar("_active_chat", default=None)
5456

5557

5658
class _ChatSingleton:
@@ -61,23 +63,35 @@ def get_state(self) -> StateAdapter: ...
6163

6264

6365
def set_chat_singleton(chat: _ChatSingleton) -> None:
64-
global _singleton
65-
_singleton = chat
66+
"""Register *chat* as the process-global default."""
67+
global _default_chat
68+
_default_chat = chat
6669

6770

6871
def get_chat_singleton() -> _ChatSingleton:
69-
if _singleton is None:
70-
raise RuntimeError("No Chat singleton registered. Call chat.register_singleton() first.")
71-
return _singleton
72+
"""Resolve the active Chat instance.
73+
74+
Resolution order:
75+
1. ContextVar for the current async task (set via ``chat.activate()``)
76+
2. Process-global default (set via ``set_chat_singleton()``)
77+
3. Raise RuntimeError
78+
"""
79+
ctx = _active_chat.get()
80+
if ctx is not None:
81+
return ctx
82+
if _default_chat is not None:
83+
return _default_chat
84+
raise RuntimeError("No Chat instance available. Use chat.activate() or register a singleton.")
7285

7386

7487
def has_chat_singleton() -> bool:
75-
return _singleton is not None
88+
return _active_chat.get() is not None or _default_chat is not None
7689

7790

7891
def clear_chat_singleton() -> None:
79-
global _singleton
80-
_singleton = None
92+
global _default_chat
93+
_default_chat = None
94+
_active_chat.set(None)
8195

8296

8397
# ---------------------------------------------------------------------------
@@ -687,12 +701,20 @@ def from_json(
687701
cls,
688702
data: dict[str, Any],
689703
adapter: Adapter | None = None,
704+
chat: Any = None,
690705
) -> ThreadImpl:
691706
"""Reconstruct a ThreadImpl from serialized JSON data.
692707
693-
Accepts both camelCase (canonical output of ``to_json()``) and
694-
snake_case keys for backward compatibility.
695-
Uses lazy resolution from the Chat singleton unless an adapter is provided.
708+
Parameters
709+
----------
710+
data:
711+
Serialized thread dict (camelCase or snake_case keys accepted).
712+
adapter:
713+
Explicit adapter to use. Skips singleton lookup for adapter resolution.
714+
chat:
715+
Explicit Chat instance. If provided, adapter and state are resolved
716+
from this instance instead of the singleton. Useful in multi-chat
717+
or test scenarios.
696718
"""
697719
current_msg_raw = data.get("currentMessage") or data.get("current_message")
698720
current_msg = None
@@ -711,6 +733,11 @@ def from_json(
711733
)
712734
if adapter is not None:
713735
thread._adapter = adapter
736+
elif chat is not None:
737+
adapter_name = data.get("adapterName") or data.get("adapter_name")
738+
if adapter_name:
739+
thread._adapter = chat.get_adapter(adapter_name)
740+
thread._state_adapter_instance = chat.get_state()
714741
return thread
715742

716743
@classmethod

0 commit comments

Comments
 (0)