diff --git a/cron/scheduler.py b/cron/scheduler.py index a3f04f316f..7e9304b2d5 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -1730,6 +1730,7 @@ def _seed_cron_thread_session( thread_id: str, mirror_text: str, chat_name: Optional[str] = None, + is_dm: bool = False, ) -> None: """Seed the freshly-opened cron thread's session with the brief. @@ -1740,6 +1741,16 @@ def _seed_cron_thread_session( threads as participant-shared, so no ``user_id`` is needed) and append the brief as an assistant turn via the shipped ``mirror_to_session``. + ``is_dm`` selects the seeded ``chat_type``: a thread under a DM must seed + ``chat_type="dm"`` because the user's in-thread DM reply arrives with + chat_type="dm" and ``build_session_key`` routes DM threads through the DM + arm (``...:dm::``) — a "thread"-typed seed lands in + ``...:thread::``, a row no DM reply ever resolves to + (continuation amnesia, Alice live 2026-08-20, job 8e21a957b77b). Channel + threads keep ``chat_type="thread"`` (their replies really do arrive as + threads). Same sibling-lane class as the flat seed's ``is_dm`` + (dcca9d8cfe). + Mirrors ``GatewayRunner._process_handoff``'s seed step, but standalone: cron reaches the live ``SessionStore`` through the adapter's ``_session_store`` handle rather than the gateway object. Best-effort — a @@ -1774,7 +1785,9 @@ def _seed_cron_thread_session( platform=platform_enum, chat_id=seed_chat_id, chat_name=chat_name, - chat_type="thread", + # DM threads key through the DM arm (see docstring); the + # reply's chat_type is what the seed must reproduce. + chat_type="dm" if is_dm else "thread", user_id="system:cron", user_name="Cron", thread_id=str(thread_id), @@ -3130,6 +3143,7 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option job, runtime_adapter, platform_name, chat_id, opened_thread_id, mirror_text, chat_name=origin.get("chat_name"), + is_dm=is_dm_target, ) thread_seeded = True # in_channel surface: CREATE + seed the flat channel/DM @@ -3169,6 +3183,7 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option job, runtime_adapter, platform_name, chat_id, str(delivered_message_id), mirror_text, chat_name=origin.get("chat_name"), + is_dm=is_dm_target, ) elif in_channel_surface and not origin_target: logger.warning( diff --git a/tests/cron/test_cron_thread_seed_dm_keying.py b/tests/cron/test_cron_thread_seed_dm_keying.py new file mode 100644 index 0000000000..83194c2d16 --- /dev/null +++ b/tests/cron/test_cron_thread_seed_dm_keying.py @@ -0,0 +1,97 @@ +"""Cron thread-seed must key EXACTLY like the reply that will continue it. + +Live incident (Alice, 2026-08-20 01:08, job 8e21a957b77b): the continuable +cron thread seed created its session row with chat_type="thread", but a +Slack DM thread reply arrives with chat_type="dm" — build_session_key puts +them in different rows (agent:main:slack:thread:D...: vs +agent:main:slack:dm:D...:), so the user's reply hit a session that had +never seen the brief. Channels are unaffected (channel thread replies carry +chat_type="thread"); the DM lane is the unswept sibling of the flat-seed +is_dm fix (dcca9d8cfe). + +Contract under test: the KEY of the seeded session equals the KEY the +user's in-thread reply will build. Asserting on build_session_key output — +not on SessionSource field shapes — pins the end-to-end contract. +""" + +from unittest.mock import MagicMock, patch + +from cron.scheduler import _seed_cron_thread_session +from gateway.config import Platform +from gateway.session import SessionSource, build_session_key + + +def _seeded_source(store): + store.get_or_create_session.assert_called_once() + return store.get_or_create_session.call_args[0][0] + + +def test_dm_thread_seed_key_matches_dm_reply_key(): + """A brief threaded under a Slack DM must seed the same session row a + DM in-thread reply resolves to (chat_type='dm', not 'thread').""" + store = MagicMock() + adapter = MagicMock() + adapter._session_store = store + + with patch("gateway.mirror.mirror_to_session", return_value=True): + _seed_cron_thread_session( + {"id": "j1", "name": "digest"}, adapter, "slack", + "D0BJTDCSR7C", "1787188136.448949", "Three bullets", + chat_name=None, is_dm=True, + ) + + reply_source = SessionSource( + platform=Platform.SLACK, + chat_id="D0BJTDCSR7C", + chat_type="dm", + user_id="U0B5F8EEYAD", + thread_id="1787188136.448949", + ) + assert build_session_key(_seeded_source(store)) == build_session_key( + reply_source + ), ( + "seeded key diverges from the DM reply's key — the brief lands in a " + "row no reply ever resolves to (continuation amnesia)" + ) + + +def test_channel_thread_seed_key_matches_thread_reply_key(): + """Channel behavior must NOT regress: a channel thread reply keys as + chat_type='thread' (participant-shared), and the seed must keep matching + it.""" + store = MagicMock() + adapter = MagicMock() + adapter._session_store = store + + with patch("gateway.mirror.mirror_to_session", return_value=True): + _seed_cron_thread_session( + {"id": "j2", "name": "digest"}, adapter, "slack", + "C0AAAAAAAA", "1787188000.000100", "Three bullets", + chat_name="ops", is_dm=False, + ) + + reply_source = SessionSource( + platform=Platform.SLACK, + chat_id="C0AAAAAAAA", + chat_type="thread", + user_id="U0B5F8EEYAD", + thread_id="1787188000.000100", + ) + assert build_session_key(_seeded_source(store)) == build_session_key( + reply_source + ) + + +def test_dm_seed_default_is_backward_compatible(): + """Callers that don't pass is_dm keep today's thread-keyed behavior — + the new parameter must not silently rekey non-DM call sites.""" + store = MagicMock() + adapter = MagicMock() + adapter._session_store = store + + with patch("gateway.mirror.mirror_to_session", return_value=True): + _seed_cron_thread_session( + {"id": "j3"}, adapter, "telegram", "123", "9001", "brief", + ) + + assert _seeded_source(store).chat_type == "thread"