Skip to content

Commit a62c91e

Browse files
committed
feat(chat): add message.subject + fetch_subject adapter hook
Port the core message.subject feature from upstream eb5f94a (PR #459, "feat(chat): message.subject + adapter client access"). Tracks #98. Core type system + chat binding only: - MessageSubject dataclass (snake_case, ~10 fields) + MessageSubjectParty for the assignee/author { id, name } sub-objects, mirroring the upstream TS MessageSubject interface in packages/chat/src/types.ts. - Optional fetch_subject(raw) -> MessageSubject | None hook on BaseAdapter (default returns None). Declared on BaseAdapter (not the Adapter Protocol) to match how every other optional adapter hook is declared here, so adapters that don't implement it still satisfy Adapter for type-checking. - Message.subject async accessor backed by an identity-keyed, weakly-scoped adapter registry + cached resolution future (mirrors upstream's adapterMap WeakMap and _subjectPromise). Resolved subject is cached so a second `await message.subject` does not re-call fetch_subject; raising hooks resolve to None (mirrors upstream .catch(() => null)). - Chat registers the owning adapter at the single dispatch bind site (_dispatch_to_handlers), through which every dispatched message flows. WeakMap hashability decision: Message is a plain @DataClass (eq=True) and therefore unhashable, so weakref.WeakKeyDictionary[Message, Adapter] raises TypeError. Rather than change Message's equality contract (eq=False/frozen), we key a plain dict by id(message) (object identity, matching WeakMap semantics) and register a weakref.finalize callback per message that pops the entry on GC. weakref.ref works on a plain dataclass even though hash() does not, and the finalizer closes the id() reuse hole. Out of scope (follow-ups): GitHub + Linear adapter implementations of fetch_subject, which depend on those adapters exposing their native client (.octokit / .linear_client) — not yet present. https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj
1 parent 3ba6456 commit a62c91e

5 files changed

Lines changed: 413 additions & 0 deletions

File tree

src/chat_sdk/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,8 @@
158158
MessageContext,
159159
MessageData,
160160
MessageMetadata,
161+
MessageSubject,
162+
MessageSubjectParty,
161163
ModalCloseEvent,
162164
ModalResponse,
163165
ModalSubmitEvent,
@@ -368,6 +370,8 @@
368370
"MessageContext",
369371
"MessageData",
370372
"MessageMetadata",
373+
"MessageSubject",
374+
"MessageSubjectParty",
371375
"ModalCloseEvent",
372376
"ModalResponse",
373377
"ModalSubmitEvent",

src/chat_sdk/chat.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
UserInfo,
6464
WebhookOptions,
6565
_parse_iso,
66+
set_message_adapter,
6667
)
6768

6869
# ---------------------------------------------------------------------------
@@ -2135,6 +2136,13 @@ async def _dispatch_to_handlers(
21352136
context: MessageContext | None = None,
21362137
) -> None:
21372138
"""Route a message to the correct handler chain."""
2139+
# Register the owning adapter so handlers can lazily resolve
2140+
# ``message.subject`` via the adapter's optional ``fetch_subject`` hook.
2141+
# Mirrors upstream's ``setMessageAdapter`` call at the dispatch bind
2142+
# site (packages/chat/src/chat.ts). Every dispatched message flows
2143+
# through here, so this is the single registration point.
2144+
set_message_adapter(message, adapter)
2145+
21382146
# Detect mention
21392147
message.is_mention = message.is_mention or self._detect_mention(adapter, message)
21402148

src/chat_sdk/types.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
from __future__ import annotations
77

8+
import asyncio
9+
import weakref
810
from collections.abc import AsyncIterable, Awaitable, Callable
911
from dataclasses import dataclass, field
1012
from datetime import datetime
@@ -387,6 +389,100 @@ class SerializedMessage(_SerializedMessageRequired, total=False):
387389
links: list[SerializedLinkPreview]
388390

389391

392+
@dataclass
393+
class MessageSubjectParty:
394+
"""A person referenced by a :class:`MessageSubject` (assignee/author).
395+
396+
Mirrors the inline ``{ id: string; name: string }`` shape used by
397+
upstream's ``MessageSubject.assignee`` / ``MessageSubject.author``.
398+
"""
399+
400+
id: str
401+
name: str
402+
403+
404+
@dataclass
405+
class MessageSubject:
406+
"""The external subject a message refers to (e.g. a Linear issue or GitHub PR).
407+
408+
Python port of the TS ``MessageSubject`` interface
409+
(``packages/chat/src/types.ts``). Resolved lazily via
410+
:attr:`Message.subject`, which delegates to the owning adapter's
411+
optional :meth:`Adapter.fetch_subject` hook.
412+
413+
Field names are snake_case per the Python port convention; ``raw`` is
414+
the platform-specific escape hatch.
415+
"""
416+
417+
# ``id`` and ``type`` are the only required fields upstream; everything
418+
# else is optional. ``raw`` is required upstream but defaults to ``None``
419+
# here so partially-populated subjects (e.g. in tests) construct cleanly.
420+
id: str
421+
type: str
422+
raw: Any = None
423+
assignee: MessageSubjectParty | None = None
424+
author: MessageSubjectParty | None = None
425+
description: str | None = None
426+
labels: list[str] | None = None
427+
status: str | None = None
428+
title: str | None = None
429+
url: str | None = None
430+
431+
432+
# --------------------------------------------------------------------------
433+
# Message -> Adapter registry (powers ``Message.subject``)
434+
# --------------------------------------------------------------------------
435+
#
436+
# Upstream (``packages/chat/src/message.ts``) uses
437+
# ``const adapterMap = new WeakMap<Message, Adapter>()`` so a dispatched
438+
# message can lazily ask its owning adapter to resolve its subject, without
439+
# the message holding a hard reference to the adapter and without leaking
440+
# messages after they fall out of scope.
441+
#
442+
# Python port hazard — hashability/weakref:
443+
# ``Message`` is a plain ``@dataclass`` (``eq=True``), which makes instances
444+
# *unhashable*. A ``weakref.WeakKeyDictionary[Message, Adapter]`` therefore
445+
# raises ``TypeError: unhashable type: 'Message'``. We deliberately do NOT
446+
# change ``Message`` to ``eq=False``/``frozen=True`` (that would alter its
447+
# public equality contract). Instead we key a plain ``dict`` by
448+
# ``id(message)`` (object identity, matching ``WeakMap`` semantics) and
449+
# register a ``weakref.finalize`` callback per message that pops the entry
450+
# when the message is garbage-collected. ``weakref.ref(message)`` works on a
451+
# plain dataclass even though ``hash()`` does not, so this is safe. The
452+
# finalizer also closes the ``id()`` reuse hole: the entry is removed before
453+
# CPython can recycle the id for a new object.
454+
_message_adapter_map: dict[int, Adapter] = {}
455+
456+
457+
def set_message_adapter(message: Message, adapter: Adapter) -> None:
458+
"""Register the adapter that owns ``message`` (powers ``message.subject``).
459+
460+
Called by :class:`~chat_sdk.chat.Chat` at the dispatch bind site so every
461+
message handed to a handler can resolve its subject via the adapter's
462+
optional :meth:`Adapter.fetch_subject` hook.
463+
464+
Mirrors upstream ``setMessageAdapter`` (``packages/chat/src/message.ts``).
465+
The mapping is keyed by object identity and weakly scoped: when ``message``
466+
is garbage-collected, its entry is removed automatically.
467+
"""
468+
key = id(message)
469+
_message_adapter_map[key] = adapter
470+
471+
# Drop the entry when the message is GC'd. A zero-arg closure (rather than
472+
# ``weakref.finalize(message, dict.pop, key, None)``) captures ``key`` and
473+
# keeps the finalizer callable's type unambiguous for the type-checker.
474+
# ``pop(key, None)`` is a no-op if the entry was already removed.
475+
def _cleanup() -> None:
476+
_message_adapter_map.pop(key, None)
477+
478+
weakref.finalize(message, _cleanup)
479+
480+
481+
def _get_message_adapter(message: Message) -> Adapter | None:
482+
"""Return the adapter registered for ``message``, or ``None``."""
483+
return _message_adapter_map.get(id(message))
484+
485+
390486
def _strip_none(d: dict[str, Any]) -> dict[str, Any]:
391487
"""Remove keys whose value is ``None`` from a dict.
392488
@@ -412,6 +508,63 @@ class Message:
412508
links: list[LinkPreview] | None = None
413509
raw: Any = None
414510

511+
# Cached awaitable for ``subject``. Mirrors upstream's ``_subjectPromise``:
512+
# the first ``await message.subject`` stores the in-flight future here so a
513+
# second access reuses it instead of re-calling ``fetch_subject``.
514+
# ``init=False``/``compare=False``/``repr=False`` keep it out of the
515+
# dataclass ``__init__``, equality, and ``repr`` — it is purely internal
516+
# resolution state, not message data.
517+
_subject_future: Any = field(default=None, init=False, compare=False, repr=False)
518+
519+
async def _resolve_subject(self) -> MessageSubject | None:
520+
"""Resolve the subject via the owning adapter's ``fetch_subject`` hook.
521+
522+
Returns ``None`` when no adapter is registered, the adapter has no
523+
``fetch_subject`` hook, the hook returns ``None``, or the hook raises
524+
(failures are swallowed, mirroring upstream's ``.catch(() => null)``).
525+
"""
526+
adapter = _get_message_adapter(self)
527+
fetch_subject = getattr(adapter, "fetch_subject", None)
528+
if adapter is None or fetch_subject is None:
529+
return None
530+
try:
531+
return await fetch_subject(self.raw)
532+
except Exception:
533+
return None
534+
535+
async def _subject(self) -> MessageSubject | None:
536+
"""Coroutine backing the :attr:`subject` accessor (caches the result).
537+
538+
The first await schedules ``_resolve_subject`` once via
539+
``ensure_future`` and stores the shared future on the instance; every
540+
later/concurrent await reuses it, so ``fetch_subject`` runs at most
541+
once. Mirrors upstream's cached ``_subjectPromise``.
542+
"""
543+
if self._subject_future is None:
544+
self._subject_future = asyncio.ensure_future(self._resolve_subject())
545+
return await self._subject_future
546+
547+
@property
548+
def subject(self) -> Awaitable[MessageSubject | None]:
549+
"""The external subject this message refers to (issue, PR, etc.), or ``None``.
550+
551+
Lazily resolved via the owning adapter's optional
552+
:meth:`Adapter.fetch_subject` hook. The adapter is registered at
553+
dispatch time by :func:`set_message_adapter`.
554+
555+
Mirrors upstream ``Message.subject`` (``packages/chat/src/message.ts``):
556+
it is an awaitable, the result is cached after the first access, and a
557+
second ``await message.subject`` does NOT re-call ``fetch_subject``.
558+
Concurrent awaits share a single in-flight resolution.
559+
560+
Usage::
561+
562+
subject = await message.subject
563+
if subject is not None:
564+
...
565+
"""
566+
return self._subject()
567+
415568
def to_json(self) -> dict[str, Any]:
416569
"""Serialize to JSON-compatible dict.
417570
@@ -1257,6 +1410,17 @@ async def get_user(self, user_id: str) -> UserInfo | None:
12571410
"""
12581411
return None
12591412

1413+
# NOTE: ``fetch_subject`` is intentionally NOT declared here. Upstream's
1414+
# ``Adapter.fetchSubject`` is an *optional* member (``fetchSubject?(...)``),
1415+
# and in this Python port the established convention for optional adapter
1416+
# hooks (``stream``, ``open_dm``, ``rehydrate_attachment``,
1417+
# ``get_channel_visibility``, ...) is to declare them on :class:`BaseAdapter`
1418+
# only — NOT on this structural ``Protocol`` — so that adapters which don't
1419+
# implement them still satisfy ``Adapter`` for type-checking. Declaring it
1420+
# on the Protocol would make it a *required* attribute and break every
1421+
# adapter that doesn't define it. :attr:`Message.subject` reads the hook via
1422+
# ``getattr(adapter, "fetch_subject", None)``, so presence is fully optional.
1423+
12601424

12611425
class BaseAdapter:
12621426
"""Base adapter with default implementations for optional methods.
@@ -1415,6 +1579,22 @@ async def get_user(self, user_id: str) -> UserInfo | None:
14151579
"""
14161580
raise ChatNotImplementedError(self.name, "getUser")
14171581

1582+
async def fetch_subject(self, raw: Any) -> MessageSubject | None:
1583+
"""Resolve the external subject a message refers to (issue, PR, etc.).
1584+
1585+
Optional — the default returns ``None`` (no subject). Adapters that
1586+
can resolve a backing entity (a Linear issue, a GitHub PR, etc.) from a
1587+
message's raw payload should override this. Unlike most optional
1588+
:class:`BaseAdapter` hooks it does *not* raise
1589+
:class:`~chat_sdk.errors.ChatNotImplementedError`, because
1590+
:attr:`Message.subject` is best-effort: "this adapter has no subject
1591+
concept" is a normal, non-error outcome that maps to ``None``.
1592+
1593+
Mirrors upstream's optional ``Adapter.fetchSubject``
1594+
(``packages/chat/src/types.ts``).
1595+
"""
1596+
return None
1597+
14181598
def rehydrate_attachment(self, attachment: Attachment) -> Attachment:
14191599
"""Reconstruct ``fetch_data`` on an attachment after deserialization.
14201600

tests/test_chat_faithful.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
ConcurrencyConfig,
3535
EmojiValue,
3636
MessageContext,
37+
MessageSubject,
3738
ModalSubmitEvent,
3839
QueueEntry,
3940
ReactionEvent,
@@ -3889,3 +3890,43 @@ async def test_should_not_cache_incoming_messages_when_adapter_does_not_set_pers
38893890

38903891
history_keys = [k for k in state.cache if k.startswith("msg-history:")]
38913892
assert len(history_keys) == 0
3893+
3894+
3895+
class TestSubjectBinding:
3896+
"""Dispatch registers the owning adapter so handlers can resolve message.subject."""
3897+
3898+
async def test_handler_can_resolve_subject_via_adapter_hook(self):
3899+
adapter = create_mock_adapter("slack")
3900+
expected = MessageSubject(id="ENG-1", type="issue", title="Fix it", raw={})
3901+
3902+
async def _fetch_subject(raw): # noqa: ANN001, ANN202
3903+
return expected
3904+
3905+
adapter.fetch_subject = _fetch_subject # type: ignore[attr-defined]
3906+
chat, adapter, state = await _init_chat(adapter=adapter)
3907+
3908+
resolved: list[MessageSubject | None] = []
3909+
3910+
@chat.on_subscribed_message
3911+
async def handler(thread, message, context=None):
3912+
resolved.append(await message.subject)
3913+
3914+
await state.subscribe("slack:C123:1234.5678")
3915+
msg = create_test_message("msg-1", "Follow up")
3916+
await chat.handle_incoming_message(adapter, "slack:C123:1234.5678", msg)
3917+
3918+
assert resolved == [expected]
3919+
3920+
async def test_subject_is_none_when_adapter_has_no_fetch_subject_hook(self):
3921+
chat, adapter, state = await _init_chat()
3922+
resolved: list[MessageSubject | None] = []
3923+
3924+
@chat.on_subscribed_message
3925+
async def handler(thread, message, context=None):
3926+
resolved.append(await message.subject)
3927+
3928+
await state.subscribe("slack:C123:1234.5678")
3929+
msg = create_test_message("msg-1", "Follow up")
3930+
await chat.handle_incoming_message(adapter, "slack:C123:1234.5678", msg)
3931+
3932+
assert resolved == [None]

0 commit comments

Comments
 (0)