diff --git a/gateway/run_topics.py b/gateway/run_topics.py index e1445f7b40..06be67b11e 100644 --- a/gateway/run_topics.py +++ b/gateway/run_topics.py @@ -435,6 +435,14 @@ class GatewayTopicThreadsMixin: copied_source = source with suppress(Exception): copied_source = dataclasses.replace(source) + # ``dataclasses.replace`` intentionally copies only declared fields. + # Preserve the in-process transport-owner ref that build_source() + # stamps on live inbound events; multiplex routed profiles need it + # for side effects such as Discord thread rename, because the + # runtime profile may not own the Discord adapter/token. + transport_ref = getattr(source, "_transport_adapter_ref", None) + if transport_ref is not None: + setattr(copied_source, "_transport_adapter_ref", transport_ref) future = safe_schedule_threadsafe( make_coro(copied_source), loop, logger=logger, log_message=f"{label} failed to schedule", ) diff --git a/tests/gateway/test_session_title_rename_lane.py b/tests/gateway/test_session_title_rename_lane.py index 5a99805d43..84f97571d1 100644 --- a/tests/gateway/test_session_title_rename_lane.py +++ b/tests/gateway/test_session_title_rename_lane.py @@ -10,10 +10,12 @@ ten minutes, so the throwaway can be the one that survives. from __future__ import annotations import types +import weakref import pytest from gateway.config import Platform +from gateway.session import SessionSource from gateway.run import GatewayRunner from gateway.run_turn_runner import TurnRunner @@ -56,7 +58,7 @@ def test_the_rename_waits_for_the_model_title(lane): assert renames == ["Fix flaky auth test"] -@pytest.mark.asyncio +@pytest.mark.anyio async def test_native_thread_rename_passes_only_the_initial_name_guard(): """The shared rename lane must honor the strict native adapter contract.""" calls: list[tuple[str, str, str | None]] = [] @@ -102,3 +104,53 @@ async def test_native_thread_rename_passes_only_the_initial_name_guard(): ) assert calls == [("999", "Semantic Session Title", "Initial words")] + + +def test_title_thread_copy_preserves_transport_adapter_ref(monkeypatch): + """Multiplex-routed sources must keep their transport owner for side effects.""" + captured_sources = [] + + class Adapter: + pass + + adapter = Adapter() + + async def noop(): + return None + + def fake_schedule(coro, loop, logger=None, log_message=None): + # The test only inspects the source passed into make_coro; close the + # coroutine so pytest does not warn about an unawaited object. + coro.close() + return None + + monkeypatch.setattr("gateway.run.safe_schedule_threadsafe", fake_schedule) + + source = SessionSource( + platform=Platform.DISCORD, + chat_id="thread-1", + chat_type="thread", + thread_id="thread-1", + profile="runtime-profile", + auto_thread_created=True, + auto_thread_initial_name="Initial words", + ) + source._transport_adapter_ref = weakref.ref(adapter) + + runner = types.SimpleNamespace( + _gateway_loop=types.SimpleNamespace(is_closed=lambda: False), + _schedule_rename_from_title_thread=GatewayRunner._schedule_rename_from_title_thread, + ) + + runner._schedule_rename_from_title_thread( + runner, + source, + lambda copied: captured_sources.append(copied) or noop(), + "Discord semantic thread rename", + ) + + assert len(captured_sources) == 1 + copied = captured_sources[0] + assert copied is not source + assert copied.profile == "runtime-profile" + assert copied._transport_adapter_ref() is adapter