diff --git a/gateway/run.py b/gateway/run.py index 24d501b5b752..c78dc69ae309 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -5585,6 +5585,12 @@ def _title_failure_cb(task: str, exc: BaseException) -> None: effective_session_id, title, ) + elif self._runner._is_mattermost_banner_thread_lane(ctx.source): + maybe_auto_title_kwargs["title_callback"] = lambda title: self._runner._schedule_mattermost_thread_title_rename( + ctx.source, + effective_session_id, + title, + ) maybe_auto_title( getattr(self._runner._session_db, "_db", self._runner._session_db), effective_session_id, @@ -19768,6 +19774,72 @@ def _log_rename_failure(fut) -> None: future.add_done_callback(_log_rename_failure) + def _is_mattermost_banner_thread_lane(self, source: SessionSource) -> bool: + """True for Mattermost thread sessions that may sit under a bot banner root. + + Kept deliberately thin: whether the thread root actually is a + retitleable session banner (bot-owned + stamped props) is verified + by the adapter's ``rename_thread`` against the live post, so user + created threads simply no-op there. + """ + return source.platform == Platform.MATTERMOST and bool(source.thread_id) + + async def _rename_mattermost_thread_for_session_title( + self, + source: SessionSource, + session_id: str, + title: str, + ) -> None: + """Best-effort write of a generated session title into the Mattermost banner root.""" + if not self._is_mattermost_banner_thread_lane(source): + return + adapter = self._adapter_for_source(source) if getattr(self, "adapters", None) else None + if adapter is None: + return + rename_thread = getattr(adapter, "rename_thread", None) + if rename_thread is None: + return + try: + await rename_thread(str(source.thread_id), title) + except Exception: + logger.debug("Failed to rename Mattermost thread for generated session title", exc_info=True) + + def _schedule_mattermost_thread_title_rename( + self, + source: SessionSource, + session_id: str, + title: str, + ) -> None: + """Schedule Mattermost banner retitle from the auto-title background thread.""" + if not title or not self._is_mattermost_banner_thread_lane(source): + return + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = getattr(self, "_gateway_loop", None) + if loop is None or loop.is_closed(): + return + try: + copied_source = dataclasses.replace(source) + except Exception: + copied_source = source + future = safe_schedule_threadsafe( + self._rename_mattermost_thread_for_session_title(copied_source, session_id, title), + loop, + logger=logger, + log_message="Mattermost thread retitle failed to schedule", + ) + if future is None: + return + + def _log_mm_rename_failure(fut) -> None: + try: + fut.result() + except Exception: + logger.debug("Mattermost thread retitle failed", exc_info=True) + + future.add_done_callback(_log_mm_rename_failure) + async def _rename_telegram_topic_for_session_title( self, source: SessionSource, diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 7b87e435055c..d1ac51bea9c1 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -321,9 +321,60 @@ async def _handle_reset_command(self, event: MessageEvent) -> Union[str, Ephemer except Exception: _tip_line = "" - if session_info: - return EphemeralReply(f"{header}\n\n{session_info}{_tip_line}") - return EphemeralReply(f"{header}{_tip_line}") + final_text = ( + f"{header}\n\n{session_info}{_tip_line}" if session_info else f"{header}{_tip_line}" + ) + + # Mattermost DM: when /new arrives outside a thread, post the reset + # banner as a flat root post via the adapter instead of the normal + # reply path. reply_mode=thread would anchor the banner under the + # user's own "/new" message, making the *user's* post the thread + # root — which the bot can never retitle. A flat bot-owned banner + # becomes the root of the next conversation thread, and the + # auto-title callback later rewrites it (Mattermost threads have no + # title field; the root post's text is what the Threads list shows). + if ( + source.platform == Platform.MATTERMOST + and (source.chat_type or "").lower() == "dm" + and not source.thread_id + ): + adapter = self.adapters.get(source.platform) if getattr(self, "adapters", None) else None + send_banner = getattr(adapter, "send_session_banner", None) + if send_banner is not None: + # Re-derive the sanitized manual title (pure function, no + # side effects): the banner collapses to this after the + # first exchange, mirroring the auto-title flow. + _manual_title = "" + if _title_arg: + try: + from hermes_state import SessionDB as _SDB + _manual_title = _SDB.sanitize_title(_title_arg) or "" + except Exception: + _manual_title = "" + hint_key = ( + "gateway.reset.mattermost_thread_hint_titled" + if _manual_title + else "gateway.reset.mattermost_thread_hint" + ) + banner_text = f"{final_text}\n\n{t(hint_key)}" + try: + banner_id = await send_banner( + source.chat_id, + banner_text, + manual_title=_manual_title or None, + ) + except Exception: + logger.debug( + "Mattermost session banner send failed; falling back to reply path", + exc_info=True, + ) + banner_id = None + if banner_id: + # Banner delivered by the adapter; suppress the normal + # reply so the reset notice isn't posted twice. + return EphemeralReply("") + + return EphemeralReply(final_text) async def _handle_profile_command(self, event: MessageEvent) -> str: """Handle /profile — show the profile serving this source and its home. diff --git a/locales/en.yaml b/locales/en.yaml index 72712986c795..0f6a45bb3803 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -252,6 +252,8 @@ gateway: title_error_untitled: "\n⚠️ {error} — session started untitled." title_empty_untitled: "\n⚠️ Title is empty after cleanup — session started untitled." tip: "\n✦ Tip: {tip}" + mattermost_thread_hint: "💬 Reply in this thread to start the conversation — it will be titled automatically." + mattermost_thread_hint_titled: "💬 Reply in this thread to start the conversation." restart: in_progress: "⏳ Gateway restart already in progress..." diff --git a/plugins/platforms/mattermost/adapter.py b/plugins/platforms/mattermost/adapter.py index c1239b37def5..7551e2a1e755 100644 --- a/plugins/platforms/mattermost/adapter.py +++ b/plugins/platforms/mattermost/adapter.py @@ -381,6 +381,108 @@ async def _resolve_root_id(self, post_id: str) -> str: return data["root_id"] return post_id + # ------------------------------------------------------------------ + # Session banner & thread title (auto-title support) + # ------------------------------------------------------------------ + + _SESSION_BANNER_PROP = "hermes_session_banner" + _THREAD_TITLE_MAX_CHARS = 80 + # What a banner collapses to once its session has a title (auto or + # manual). The root post's text is what the Threads list shows. + _TITLED_BANNER_FORMAT = "💬 {title}" + + def _auto_title_enabled(self) -> bool: + """Check if session-banner threads + auto-title are enabled via env.""" + return os.getenv("MATTERMOST_AUTO_TITLE", "true").lower() not in {"false", "0", "no"} + + def _sanitize_thread_title(self, title: str) -> str: + """Collapse whitespace and cap length for a banner title line.""" + cleaned = re.sub(r"\s+", " ", str(title or "")).strip() + if len(cleaned) > self._THREAD_TITLE_MAX_CHARS: + cleaned = cleaned[: self._THREAD_TITLE_MAX_CHARS - 3].rstrip() + "..." + return cleaned + + async def send_session_banner( + self, + chat_id: str, + text: str, + manual_title: Optional[str] = None, + ) -> Optional[str]: + """Post a flat (non-threaded) session banner and return its post ID. + + The banner becomes the root of a fresh conversation thread. Replies + under it get their own gateway session, and the title callback later + collapses the banner text via ``rename_thread`` — Mattermost threads + have no title field, so the root post's text *is* the title shown in + the Threads list. ``manual_title`` records a user-picked ``/new + `` name: the banner then collapses to THAT title instead of + the generated one. + """ + if not self._auto_title_enabled(): + return None + if not chat_id or not text or self._session is None: + return None + banner_meta: Dict[str, Any] = {"auto_title": manual_title is None} + if manual_title: + banner_meta["manual_title"] = manual_title + data = await self._api_post( + "posts", + { + "channel_id": chat_id, + "message": text, + "props": {self._SESSION_BANNER_PROP: banner_meta}, + }, + ) + post_id = data.get("id") if data else None + return str(post_id) if post_id else None + + async def rename_thread(self, thread_id: str, title: str) -> bool: + """Collapse a banner root post to its session title line. + + Only rewrites posts the bot itself created AND stamped with the + session-banner prop — a user's own thread root (or any ordinary bot + reply someone started a thread on) is never touched. The reset + notice and thread hint have served their purpose once the user is + chatting in the thread, so the banner becomes a single + ``💬 <title>`` line (which is also what the Threads list shows). + Manual ``/new <title>`` banners collapse to the user's own title + instead of the generated one. + """ + if not self._auto_title_enabled(): + return False + title = self._sanitize_thread_title(title) + if not title or not thread_id or self._session is None: + return False + post = await self._api_get(f"posts/{thread_id}") + if not post or post.get("id") != thread_id: + return False + if post.get("root_id"): + return False # a reply, not a thread root + if post.get("user_id") != self._bot_user_id: + return False # not our post — never rewrite user content + props = post.get("props") or {} + banner_meta = props.get(self._SESSION_BANNER_PROP) + if not isinstance(banner_meta, dict): + return False # ordinary bot post, not a session banner + if banner_meta.get("auto_title") is False: + # /new <title>: the user picked the name — collapse to THEIR + # title, never the generated one. Legacy manual banners without + # a stored title are left untouched. + manual = self._sanitize_thread_title(str(banner_meta.get("manual_title") or "")) + if not manual: + return False + title = manual + new_props = dict(props) + new_props[self._SESSION_BANNER_PROP] = {**banner_meta, "titled": True} + data = await self._api_put( + f"posts/{thread_id}/patch", + { + "message": self._TITLED_BANNER_FORMAT.format(title=title), + "props": new_props, + }, + ) + return bool(data and data.get("id")) + async def send( self, chat_id: str, diff --git a/plugins/platforms/mattermost/plugin.yaml b/plugins/platforms/mattermost/plugin.yaml index 3ee5814cde89..0d45eaf65c3f 100644 --- a/plugins/platforms/mattermost/plugin.yaml +++ b/plugins/platforms/mattermost/plugin.yaml @@ -47,3 +47,7 @@ optional_env: description: "If set, the bot only responds in these channels (whitelist)." prompt: "Allowed channel IDs (comma-separated)" password: false + - name: MATTERMOST_AUTO_TITLE + description: "On /new in a DM, post a flat banner as the new thread root and auto-title it from the first exchange (default true)." + prompt: "Enable session-banner auto-title? (true/false)" + password: false diff --git a/tests/gateway/test_mattermost_thread_title.py b/tests/gateway/test_mattermost_thread_title.py new file mode 100644 index 000000000000..5225daed2549 --- /dev/null +++ b/tests/gateway/test_mattermost_thread_title.py @@ -0,0 +1,464 @@ +"""Tests for Mattermost session-banner threads + auto-title rename. + +``/new`` in a Mattermost DM posts a flat bot-owned banner that becomes the +root of the next conversation thread; the auto-title callback later rewrites +that banner's text. Mattermost threads have no title field — the root +post's text is what the Threads list shows, so rewriting the banner is the +platform's equivalent of Discord's thread rename / Telegram's topic rename. +""" +from unittest.mock import AsyncMock, MagicMock + +import pytest + +import gateway.run as gateway_run +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import EphemeralReply, MessageEvent, MessageType +from gateway.session import SessionSource + +BANNER_PROP = "hermes_session_banner" + + +def _make_adapter(*, bot_user_id: str = "bot_xyz"): + """Build a MattermostAdapter wired up enough to invoke banner methods.""" + from plugins.platforms.mattermost.adapter import MattermostAdapter + + config = PlatformConfig( + enabled=True, + token="test-token", + extra={"url": "https://mm.example.com"}, + ) + adapter = MattermostAdapter(config) + adapter._bot_user_id = bot_user_id + adapter._session = MagicMock() # truthy session so guards pass + adapter._api_get = AsyncMock(return_value={}) + adapter._api_post = AsyncMock(return_value={"id": "post_1"}) + adapter._api_put = AsyncMock(return_value={"id": "post_1"}) + return adapter + + +def _banner_post( + *, + post_id: str = "root_1", + user_id: str = "bot_xyz", + root_id: str = "", + message: str = "banner text", + auto_title: bool = True, + manual_title: str = "", + with_prop: bool = True, +): + post = { + "id": post_id, + "user_id": user_id, + "root_id": root_id, + "message": message, + "props": {}, + } + if with_prop: + meta = {"auto_title": auto_title} + if manual_title: + meta["manual_title"] = manual_title + post["props"][BANNER_PROP] = meta + return post + + +class TestSanitizeThreadTitle: + def test_collapses_whitespace(self): + adapter = _make_adapter() + assert adapter._sanitize_thread_title(" fix\n\nCI cache ") == "fix CI cache" + + def test_caps_length(self): + adapter = _make_adapter() + result = adapter._sanitize_thread_title("x" * 200) + assert len(result) == 80 + assert result.endswith("...") + + def test_empty_becomes_empty(self): + adapter = _make_adapter() + assert adapter._sanitize_thread_title(" ") == "" + + +class TestSendSessionBanner: + @pytest.mark.asyncio + async def test_posts_flat_with_banner_prop(self, monkeypatch): + monkeypatch.delenv("MATTERMOST_AUTO_TITLE", raising=False) + adapter = _make_adapter() + adapter._api_post = AsyncMock(return_value={"id": "banner_9"}) + + post_id = await adapter.send_session_banner("chan_1", "Session reset!") + + assert post_id == "banner_9" + adapter._api_post.assert_awaited_once() + path, payload = adapter._api_post.await_args.args + assert path == "posts" + assert payload["channel_id"] == "chan_1" + assert payload["message"] == "Session reset!" + assert "root_id" not in payload # flat post — it must BE the thread root + assert payload["props"][BANNER_PROP] == {"auto_title": True} + + @pytest.mark.asyncio + async def test_manual_title_stored_in_props(self, monkeypatch): + monkeypatch.delenv("MATTERMOST_AUTO_TITLE", raising=False) + adapter = _make_adapter() + + await adapter.send_session_banner("chan_1", "titled banner", manual_title="My topic") + + _, payload = adapter._api_post.await_args.args + assert payload["props"][BANNER_PROP] == { + "auto_title": False, + "manual_title": "My topic", + } + + @pytest.mark.asyncio + async def test_env_disabled_returns_none(self, monkeypatch): + monkeypatch.setenv("MATTERMOST_AUTO_TITLE", "false") + adapter = _make_adapter() + + assert await adapter.send_session_banner("chan_1", "text") is None + adapter._api_post.assert_not_awaited() + + @pytest.mark.asyncio + async def test_api_failure_returns_none(self, monkeypatch): + monkeypatch.delenv("MATTERMOST_AUTO_TITLE", raising=False) + adapter = _make_adapter() + adapter._api_post = AsyncMock(return_value={}) + + assert await adapter.send_session_banner("chan_1", "text") is None + + @pytest.mark.asyncio + async def test_missing_args_return_none(self, monkeypatch): + monkeypatch.delenv("MATTERMOST_AUTO_TITLE", raising=False) + adapter = _make_adapter() + + assert await adapter.send_session_banner("", "text") is None + assert await adapter.send_session_banner("chan_1", "") is None + adapter._api_post.assert_not_awaited() + + +class TestRenameThread: + @pytest.mark.asyncio + async def test_collapses_banner_to_title_line(self, monkeypatch): + """The reset notice + thread hint are dropped once the title lands.""" + monkeypatch.delenv("MATTERMOST_AUTO_TITLE", raising=False) + adapter = _make_adapter() + adapter._api_get = AsyncMock(return_value=_banner_post(message="Session reset!")) + + assert await adapter.rename_thread("root_1", "Fix CI cache") is True + + adapter._api_put.assert_awaited_once() + path, payload = adapter._api_put.await_args.args + assert path == "posts/root_1/patch" + assert payload["message"] == "💬 Fix CI cache" + assert payload["props"][BANNER_PROP]["titled"] is True + + @pytest.mark.asyncio + async def test_second_rename_replaces_title(self, monkeypatch): + """A titled banner renames cleanly — new title replaces, never stacks.""" + monkeypatch.delenv("MATTERMOST_AUTO_TITLE", raising=False) + adapter = _make_adapter() + already_titled = _banner_post(message="💬 Old title") + already_titled["props"][BANNER_PROP]["titled"] = True + adapter._api_get = AsyncMock(return_value=already_titled) + + await adapter.rename_thread("root_1", "New topic") + + _, payload = adapter._api_put.await_args.args + assert payload["message"] == "💬 New topic" + + @pytest.mark.asyncio + async def test_never_rewrites_user_posts(self, monkeypatch): + monkeypatch.delenv("MATTERMOST_AUTO_TITLE", raising=False) + adapter = _make_adapter() + adapter._api_get = AsyncMock( + return_value=_banner_post(user_id="human_1", with_prop=False) + ) + + assert await adapter.rename_thread("root_1", "title") is False + adapter._api_put.assert_not_awaited() + + @pytest.mark.asyncio + async def test_skips_ordinary_bot_posts_without_prop(self, monkeypatch): + """A user opening a thread on a normal bot reply must not rewrite it.""" + monkeypatch.delenv("MATTERMOST_AUTO_TITLE", raising=False) + adapter = _make_adapter() + adapter._api_get = AsyncMock(return_value=_banner_post(with_prop=False)) + + assert await adapter.rename_thread("root_1", "title") is False + adapter._api_put.assert_not_awaited() + + @pytest.mark.asyncio + async def test_manual_banner_collapses_to_manual_title(self, monkeypatch): + """/new <title> banners collapse to the USER's title, never the generated one.""" + monkeypatch.delenv("MATTERMOST_AUTO_TITLE", raising=False) + adapter = _make_adapter() + adapter._api_get = AsyncMock( + return_value=_banner_post(auto_title=False, manual_title="Say hello") + ) + + assert await adapter.rename_thread("root_1", "AI generated title") is True + + _, payload = adapter._api_put.await_args.args + assert payload["message"] == "💬 Say hello" + + @pytest.mark.asyncio + async def test_legacy_manual_banner_without_stored_title_untouched(self, monkeypatch): + monkeypatch.delenv("MATTERMOST_AUTO_TITLE", raising=False) + adapter = _make_adapter() + adapter._api_get = AsyncMock(return_value=_banner_post(auto_title=False)) + + assert await adapter.rename_thread("root_1", "title") is False + adapter._api_put.assert_not_awaited() + + @pytest.mark.asyncio + async def test_skips_replies(self, monkeypatch): + monkeypatch.delenv("MATTERMOST_AUTO_TITLE", raising=False) + adapter = _make_adapter() + adapter._api_get = AsyncMock(return_value=_banner_post(root_id="other_root")) + + assert await adapter.rename_thread("root_1", "title") is False + adapter._api_put.assert_not_awaited() + + @pytest.mark.asyncio + async def test_env_disabled_skips_everything(self, monkeypatch): + monkeypatch.setenv("MATTERMOST_AUTO_TITLE", "0") + adapter = _make_adapter() + + assert await adapter.rename_thread("root_1", "title") is False + adapter._api_get.assert_not_awaited() + + @pytest.mark.asyncio + async def test_empty_title_or_fetch_failure(self, monkeypatch): + monkeypatch.delenv("MATTERMOST_AUTO_TITLE", raising=False) + adapter = _make_adapter() + + assert await adapter.rename_thread("root_1", " ") is False + + adapter._api_get = AsyncMock(return_value={}) + assert await adapter.rename_thread("root_1", "title") is False + adapter._api_put.assert_not_awaited() + + +def _mm_source(thread_id=None, chat_type="dm"): + return SessionSource( + platform=Platform.MATTERMOST, + chat_id="chan_1", + chat_type=chat_type, + user_id="user_1", + thread_id=thread_id, + message_id="msg_1", + ) + + +class TestMattermostBannerThreadLane: + def _runner(self): + return object.__new__(gateway_run.GatewayRunner) + + def test_thread_session_is_lane(self): + assert self._runner()._is_mattermost_banner_thread_lane(_mm_source(thread_id="root_1")) is True + + def test_flat_dm_is_not_lane(self): + assert self._runner()._is_mattermost_banner_thread_lane(_mm_source()) is False + + def test_other_platform_is_not_lane(self): + source = SessionSource( + platform=Platform.DISCORD, chat_id="c", chat_type="thread", thread_id="t" + ) + assert self._runner()._is_mattermost_banner_thread_lane(source) is False + + +class TestRenameMattermostThreadForSessionTitle: + def _runner(self, adapter): + runner = object.__new__(gateway_run.GatewayRunner) + runner.adapters = {Platform.MATTERMOST: adapter} + runner._adapter_for_source = MagicMock(return_value=adapter) + return runner + + @pytest.mark.asyncio + async def test_calls_adapter_rename(self): + adapter = MagicMock() + adapter.rename_thread = AsyncMock(return_value=True) + runner = self._runner(adapter) + + await runner._rename_mattermost_thread_for_session_title( + _mm_source(thread_id="root_1"), "sid_1", "Fix CI cache" + ) + + adapter.rename_thread.assert_awaited_once_with("root_1", "Fix CI cache") + + @pytest.mark.asyncio + async def test_non_lane_source_is_ignored(self): + adapter = MagicMock() + adapter.rename_thread = AsyncMock() + runner = self._runner(adapter) + + await runner._rename_mattermost_thread_for_session_title( + _mm_source(thread_id=None), "sid_1", "title" + ) + + adapter.rename_thread.assert_not_awaited() + + @pytest.mark.asyncio + async def test_adapter_without_rename_thread_is_ignored(self): + adapter = object() # no rename_thread attribute at all + runner = self._runner(adapter) + + # Must not raise. + await runner._rename_mattermost_thread_for_session_title( + _mm_source(thread_id="root_1"), "sid_1", "title" + ) + + +def _make_reset_runner(adapter=None, session_db=None): + """GatewayRunner with just enough wiring to run _handle_reset_command.""" + runner = object.__new__(gateway_run.GatewayRunner) + runner._session_key_for_source = MagicMock(return_value="agent:main:mattermost:dm:chan_1") + runner._invalidate_session_run_generation = MagicMock() + runner._release_running_agent_state = MagicMock() + runner.session_store = MagicMock() + runner.session_store._entries = {} + new_entry = MagicMock() + new_entry.session_id = "sid_new" + runner.session_store.get_or_create_session = MagicMock(return_value=new_entry) + # async_session_store is a read-only property; seed its backing attribute + # with a facade whose _store matches so the property returns it as-is. + async_store = MagicMock() + async_store._store = runner.session_store + async_store.reset_session = AsyncMock(return_value=new_entry) + runner._async_session_store = async_store + runner._agent_cache_lock = None + runner._evict_cached_agent = MagicMock() + runner._queued_events = None + runner._session_model_overrides = {} + runner._set_session_reasoning_override = MagicMock() + runner._clear_session_boundary_security_state = MagicMock() + runner.hooks = MagicMock() + runner.hooks.emit = AsyncMock() + runner._reset_notice_session_info = MagicMock(return_value="") + runner._telegram_topic_new_header = MagicMock(return_value=None) + runner._is_telegram_topic_lane = MagicMock(return_value=False) + runner._session_db = session_db + runner.adapters = {Platform.MATTERMOST: adapter} if adapter is not None else {} + return runner + + +def _reset_event(text="/new", source=None): + return MessageEvent( + text=text, + message_type=MessageType.COMMAND, + source=source or _mm_source(), + message_id="msg_1", + ) + + +@pytest.fixture +def _isolated_side_effects(monkeypatch): + """Neutralize module-level side effects the reset handler triggers.""" + monkeypatch.setattr( + "tools.async_delegation.interrupt_for_session", MagicMock(), raising=False + ) + monkeypatch.setattr( + "tools.env_passthrough.clear_env_passthrough", MagicMock(), raising=False + ) + monkeypatch.setattr( + "tools.credential_files.clear_credential_files", MagicMock(), raising=False + ) + monkeypatch.setattr("hermes_cli.plugins.invoke_hook", MagicMock(), raising=False) + monkeypatch.setattr( + "hermes_cli.tips.get_random_tip", MagicMock(return_value="tip"), raising=False + ) + + +class TestResetCommandMattermostBanner: + @pytest.mark.asyncio + async def test_flat_dm_new_posts_banner_and_suppresses_reply( + self, monkeypatch, _isolated_side_effects + ): + monkeypatch.delenv("MATTERMOST_AUTO_TITLE", raising=False) + adapter = MagicMock() + adapter.send_session_banner = AsyncMock(return_value="banner_1") + runner = _make_reset_runner(adapter) + + result = await runner._handle_reset_command(_reset_event()) + + adapter.send_session_banner.assert_awaited_once() + args, kwargs = adapter.send_session_banner.await_args + assert args[0] == "chan_1" + assert "Reply in this thread" in args[1] + assert "titled automatically" in args[1] + assert kwargs.get("manual_title") is None + assert isinstance(result, EphemeralReply) + assert str(result) == "" # normal reply suppressed — banner carries it + + @pytest.mark.asyncio + async def test_manual_title_passed_with_short_hint( + self, monkeypatch, _isolated_side_effects + ): + session_db = MagicMock() + session_db.set_session_title = AsyncMock(return_value=True) + adapter = MagicMock() + adapter.send_session_banner = AsyncMock(return_value="banner_1") + runner = _make_reset_runner(adapter, session_db=session_db) + + result = await runner._handle_reset_command(_reset_event(text="/new My topic")) + + args, kwargs = adapter.send_session_banner.await_args + assert kwargs.get("manual_title") == "My topic" + assert "Reply in this thread" in args[1] + assert "titled automatically" not in args[1] # manual banner: short hint + assert str(result) == "" + + @pytest.mark.asyncio + async def test_banner_failure_falls_back_to_reply_path( + self, monkeypatch, _isolated_side_effects + ): + adapter = MagicMock() + adapter.send_session_banner = AsyncMock(return_value=None) + runner = _make_reset_runner(adapter) + + result = await runner._handle_reset_command(_reset_event()) + + assert isinstance(result, EphemeralReply) + assert str(result) != "" # reset notice still reaches the user + + @pytest.mark.asyncio + async def test_new_inside_thread_keeps_reply_path( + self, monkeypatch, _isolated_side_effects + ): + """/new inside an existing thread resets in place — no new banner.""" + adapter = MagicMock() + adapter.send_session_banner = AsyncMock(return_value="banner_1") + runner = _make_reset_runner(adapter) + + result = await runner._handle_reset_command( + _reset_event(source=_mm_source(thread_id="root_1")) + ) + + adapter.send_session_banner.assert_not_awaited() + assert str(result) != "" + + @pytest.mark.asyncio + async def test_non_mattermost_platform_untouched( + self, monkeypatch, _isolated_side_effects + ): + adapter = MagicMock() + adapter.send_session_banner = AsyncMock(return_value="banner_1") + runner = _make_reset_runner(adapter) + source = SessionSource( + platform=Platform.TELEGRAM, chat_id="tg_1", chat_type="dm", user_id="u1" + ) + + result = await runner._handle_reset_command(_reset_event(source=source)) + + adapter.send_session_banner.assert_not_awaited() + assert str(result) != "" + + @pytest.mark.asyncio + async def test_adapter_without_banner_support_keeps_reply_path( + self, monkeypatch, _isolated_side_effects + ): + adapter = object() # e.g. older adapter build without send_session_banner + runner = _make_reset_runner(adapter) + + result = await runner._handle_reset_command(_reset_event()) + + assert isinstance(result, EphemeralReply) + assert str(result) != ""