"""Tests for configurable background process notification modes. The gateway process watcher pushes status updates to users' chats when background terminal commands run. ``display.background_process_notifications`` controls verbosity: off | result | error | all (default). Contributed by @PeterFile (PR #593), reimplemented on current main. """ import asyncio import queue import threading from types import SimpleNamespace from unittest.mock import AsyncMock import pytest from gateway.config import GatewayConfig, Platform from gateway.run import GatewayRunner, _parse_session_key from gateway.run_notifications import INTERNAL_NOTIFICATION_FOOTER # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- class AdmittingHandler(AsyncMock): """Fake transport whose successful insertion issues the production receipt.""" async def _execute_mock_call(self, event, *args, **kwargs): result = await super()._execute_mock_call(event, *args, **kwargs) event._gateway_accepted = True return result class _FakeRegistry: """Return pre-canned sessions, then None once exhausted.""" def __init__(self, sessions, consumed=False): self._sessions = list(sessions) self._consumed = consumed def get(self, session_id): if self._sessions: return self._sessions.pop(0) return None def is_completion_consumed(self, session_id): return self._consumed def _build_runner(monkeypatch, tmp_path, mode: str) -> GatewayRunner: """Create a GatewayRunner with a fake config for the given mode.""" (tmp_path / "config.yaml").write_text( f"display:\n background_process_notifications: {mode}\n", encoding="utf-8", ) import gateway.run as gateway_run monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) runner = GatewayRunner(GatewayConfig()) adapter = SimpleNamespace(send=AsyncMock(), handle_message=AdmittingHandler()) runner.adapters[Platform.TELEGRAM] = adapter return runner def _watcher_dict(session_id="proc_test", thread_id=""): d = { "session_id": session_id, "check_interval": 0, "platform": "telegram", "chat_id": "123", } if thread_id: d["thread_id"] = thread_id return d def _watch_event(session_id="proc_watch", thread_id="42"): return { "type": "watch_match", "session_id": session_id, "session_key": f"agent:main:telegram:dm:123:{thread_id}", "pattern": "READY", "command": "build", "output": "READY\n", } # --------------------------------------------------------------------------- # _load_background_notifications_mode unit tests # --------------------------------------------------------------------------- class TestLoadBackgroundNotificationsMode: def test_unknown_mode_falls_back_to_concise(self, monkeypatch, tmp_path): (tmp_path / "config.yaml").write_text( "display:\n background_process_notifications: bogus\n" ) import gateway.run as gw monkeypatch.setattr(gw, "_hermes_home", tmp_path) monkeypatch.delenv("HERMES_BACKGROUND_NOTIFICATIONS", raising=False) assert GatewayRunner._load_background_notifications_mode() == "concise" def test_reads_config_yaml(self, monkeypatch, tmp_path): (tmp_path / "config.yaml").write_text( "display:\n background_process_notifications: error\n" ) import gateway.run as gw monkeypatch.setattr(gw, "_hermes_home", tmp_path) monkeypatch.delenv("HERMES_BACKGROUND_NOTIFICATIONS", raising=False) assert GatewayRunner._load_background_notifications_mode() == "error" # --------------------------------------------------------------------------- # _run_process_watcher integration tests # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_consumed_completion_skips_raw_notification(monkeypatch, tmp_path): """#65379: after process(wait) already returned the completion inline, the gateway watcher must NOT also push the raw "[Background process ... finished with exit code ...]" message. The agent-notify branch already honored _completion_consumed, but its skip fell through to the text-notification branch, double-delivering the same output to the chat (observed on Slack with background_process_notifications: all).""" import tools.process_registry as pr_module sessions = [SimpleNamespace( output_buffer="done\n", exited=True, exit_code=0, command="sleep 1; echo done", )] monkeypatch.setattr( pr_module, "process_registry", _FakeRegistry(sessions, consumed=True) ) async def _instant_sleep(*_a, **_kw): pass monkeypatch.setattr(asyncio, "sleep", _instant_sleep) runner = _build_runner(monkeypatch, tmp_path, "all") adapter = runner.adapters[Platform.TELEGRAM] # notify_on_complete=True mirrors the reported scenario: the watcher's # agent-notify skip must not fall through to a raw adapter.send(). watcher = _watcher_dict() watcher["notify_on_complete"] = True await runner._run_process_watcher(watcher) adapter.send.assert_not_awaited() adapter.handle_message.assert_not_awaited() @pytest.mark.asyncio @pytest.mark.parametrize("launching_turn_busy", [True, False]) async def test_agent_notify_receipt_only_while_launching_turn_is_busy( monkeypatch, tmp_path, launching_turn_busy ): """#112033: with notify_on_complete the agent reports the result itself, so the chat gets no separate receipt — except while the launching turn is still running, when the injection only queues a follow-up and the concise receipt is the only thing the user would see.""" import tools.process_registry as pr_module sessions = [SimpleNamespace( output_buffer="done\n", exited=True, exit_code=0, command="echo done", started_at=None, )] monkeypatch.setattr(pr_module, "process_registry", _FakeRegistry(sessions, consumed=False)) async def _instant_sleep(*_a, **_kw): pass monkeypatch.setattr(asyncio, "sleep", _instant_sleep) runner = _build_runner(monkeypatch, tmp_path, "concise") runner._enqueue_process_completion_notification = AsyncMock(return_value=True) adapter = runner.adapters[Platform.TELEGRAM] watcher = {**_watcher_dict(), "session_key": "agent:main:telegram:dm:123", "notify_on_complete": True} adapter._active_sessions = {watcher["session_key"]: asyncio.Event()} if launching_turn_busy else {} await runner._run_process_watcher(watcher) runner._enqueue_process_completion_notification.assert_awaited_once() if launching_turn_busy: adapter.send.assert_awaited_once() assert adapter.send.await_args.args[1].startswith("✅ Background task finished") else: adapter.send.assert_not_awaited() @pytest.mark.asyncio async def test_arm_process_watcher_schedules_on_live_loop_only(monkeypatch, tmp_path): """#112033: a watcher registered mid-turn starts on the gateway loop at once while the gateway serves; before/after that the caller keeps it for the startup / post-turn drain.""" runner = _build_runner(monkeypatch, tmp_path, "concise") started = [] async def _fake_watcher(watcher): started.append(watcher["session_id"]) runner._run_process_watcher = _fake_watcher runner._gateway_loop = asyncio.get_running_loop() runner._running = False assert runner.arm_process_watcher({"session_id": "proc_early"}) is False runner._running = True assert runner.arm_process_watcher({"session_id": "proc_live"}) is True await asyncio.sleep(0.05) assert started == ["proc_live"] @pytest.mark.asyncio async def test_consumed_completion_skips_raw_notification_without_agent_notify( monkeypatch, tmp_path ): """#65379 variant: same double-delivery guard for plain watchers (notify_on_complete=False) — wait/log consumption suppresses the raw completion message in every mode.""" import tools.process_registry as pr_module sessions = [SimpleNamespace( output_buffer="done\n", exited=True, exit_code=0, command="echo done", )] monkeypatch.setattr( pr_module, "process_registry", _FakeRegistry(sessions, consumed=True) ) async def _instant_sleep(*_a, **_kw): pass monkeypatch.setattr(asyncio, "sleep", _instant_sleep) runner = _build_runner(monkeypatch, tmp_path, "all") adapter = runner.adapters[Platform.TELEGRAM] await runner._run_process_watcher(_watcher_dict()) adapter.send.assert_not_awaited() @pytest.mark.asyncio async def test_inject_watch_notification_routes_from_session_store_origin(monkeypatch, tmp_path): from gateway.session import SessionSource runner = _build_runner(monkeypatch, tmp_path, "all") adapter = runner.adapters[Platform.TELEGRAM] runner.session_store._entries["agent:main:telegram:group:-100:42"] = SimpleNamespace( origin=SessionSource( platform=Platform.TELEGRAM, chat_id="-100", chat_type="group", thread_id="42", user_id="123", user_name="Emiliyan", ) ) evt = { "session_id": "proc_watch", "session_key": "agent:main:telegram:group:-100:42", } await runner._inject_watch_notification("[SYSTEM: Background process matched]", evt) adapter.handle_message.assert_awaited_once() synth_event = adapter.handle_message.await_args.args[0] assert synth_event.internal is True assert synth_event.source.platform == Platform.TELEGRAM assert synth_event.source.chat_id == "-100" assert synth_event.source.chat_type == "group" assert synth_event.source.thread_id == "42" assert synth_event.source.user_id == "123" assert synth_event.source.user_name == "Emiliyan" @pytest.mark.asyncio async def test_post_turn_watch_drain_off_consumes_without_injecting(monkeypatch, tmp_path): runner = _build_runner(monkeypatch, tmp_path, "off") adapter = runner.adapters[Platform.TELEGRAM] completion_queue = queue.Queue() completion_queue.put(_watch_event("proc_one")) completion_queue.put(_watch_event("proc_two")) async_event = {"type": "async_delegation", "session_id": "delegate_one"} completion_queue.put(async_event) await runner._drain_watch_notifications(completion_queue) adapter.handle_message.assert_not_awaited() assert completion_queue.qsize() == 1 assert completion_queue.get_nowait() is async_event @pytest.mark.asyncio async def test_post_turn_watch_drain_all_injects_from_queued_event_origin(monkeypatch, tmp_path): from gateway.session import SessionSource runner = _build_runner(monkeypatch, tmp_path, "all") adapter = runner.adapters[Platform.TELEGRAM] runner.session_store._entries["agent:main:telegram:dm:123:42"] = SimpleNamespace( origin=SessionSource( platform=Platform.TELEGRAM, chat_id="123", chat_type="dm", thread_id="42", user_id="proc_owner", user_name="alice", ) ) completion_queue = queue.Queue() completion_queue.put(_watch_event()) async_event = {"type": "async_delegation", "session_id": "delegate_one"} completion_queue.put(async_event) await runner._drain_watch_notifications(completion_queue) adapter.handle_message.assert_awaited_once() synth_event = adapter.handle_message.await_args.args[0] assert synth_event.source.thread_id == "42" assert synth_event.source.user_id == "proc_owner" assert completion_queue.qsize() == 1 assert completion_queue.get_nowait() is async_event @pytest.mark.asyncio async def test_inject_watch_notification_drops_stale_trigger_reply_anchor(monkeypatch, tmp_path): """Regression for #52694: the synthetic watch event must not reuse the triggering message id as its reply anchor. The id names the message that STARTED the process; by the time the process exits the user has often moved on, and a reply quoting that id answers a stale message (a finished background job visibly replying to an old Discord DM message from another topic). Routing stays on the persisted origin — Telegram DM-topic lanes route anchor-less synthetic sends through the topic's message_thread_id (#87051) — and the original id survives only as event metadata for debugging. """ from gateway.session import SessionSource runner = _build_runner(monkeypatch, tmp_path, "all") adapter = runner.adapters[Platform.TELEGRAM] runner.session_store._entries["agent:main:telegram:dm:123:24296"] = SimpleNamespace( origin=SessionSource( platform=Platform.TELEGRAM, chat_id="123", chat_type="dm", thread_id="24296", user_id="1", user_name="Fabio", message_id="777", # persisted origin: an equally stale triggering id ) ) evt = { "session_id": "proc_watch", "session_key": "agent:main:telegram:dm:123:24296", "message_id": "777", } await runner._inject_watch_notification("[SYSTEM: Background process matched]", evt) adapter.handle_message.assert_awaited_once() synth_event = adapter.handle_message.await_args.args[0] assert synth_event.internal is True # No stale anchor on the event OR the restored origin it routes through. assert synth_event.message_id is None assert synth_event.source.message_id is None # Routing provenance is untouched: the notification still lands in the origin topic. assert synth_event.source.thread_id == "24296" # The derived final-reply anchor is empty on every platform branch. from gateway.platforms.base import _reply_anchor_for_event assert _reply_anchor_for_event(synth_event) is None # The original id survives for debugging only. assert synth_event.metadata["original_trigger_message_id"] == "777" # Unambiguous machine provenance: SYSTEM prefix still leads, footer appended, origin tagged. assert synth_event.text.startswith("[SYSTEM: ") assert synth_event.text.rstrip().endswith(INTERNAL_NOTIFICATION_FOOTER) assert synth_event.metadata["notification_origin"] == "process_registry_synthetic" @pytest.mark.asyncio async def test_inject_watch_notification_loads_session_store_off_loop(monkeypatch, tmp_path): from gateway.session import SessionSource runner = _build_runner(monkeypatch, tmp_path, "all") adapter = runner.adapters[Platform.TELEGRAM] runner.session_store._entries["agent:main:telegram:dm:123:24296"] = SimpleNamespace( origin=SessionSource( platform=Platform.TELEGRAM, chat_id="123", chat_type="dm", thread_id="24296", user_id="1", user_name="Fabio", ) ) loop_thread = threading.get_ident() load_threads = [] real_ensure_loaded = runner.session_store._ensure_loaded def spy_ensure_loaded(): load_threads.append(threading.get_ident()) return real_ensure_loaded() monkeypatch.setattr(runner.session_store, "_ensure_loaded", spy_ensure_loaded) await runner._inject_watch_notification( "[SYSTEM: Background process matched]", { "session_id": "proc_watch", "session_key": "agent:main:telegram:dm:123:24296", "message_id": "777", }, ) adapter.handle_message.assert_awaited_once() assert load_threads assert all(thread_id != loop_thread for thread_id in load_threads) @pytest.mark.asyncio async def test_inject_watch_notification_ignores_foreground_event_source(monkeypatch, tmp_path): """Negative test: watch notification must NOT route to the foreground thread.""" from gateway.session import SessionSource runner = _build_runner(monkeypatch, tmp_path, "all") adapter = runner.adapters[Platform.TELEGRAM] # Session store has the process's original thread (thread 42) runner.session_store._entries["agent:main:telegram:group:-100:42"] = SimpleNamespace( origin=SessionSource( platform=Platform.TELEGRAM, chat_id="-100", chat_type="group", thread_id="42", user_id="proc_owner", user_name="alice", ) ) # The evt dict carries the correct session_key — NOT a foreground event evt = { "session_id": "proc_cross_thread", "session_key": "agent:main:telegram:group:-100:42", } await runner._inject_watch_notification("[SYSTEM: watch match]", evt) adapter.handle_message.assert_awaited_once() synth_event = adapter.handle_message.await_args.args[0] # Must route to thread 42 (process origin), NOT some other thread assert synth_event.source.thread_id == "42" assert synth_event.source.user_id == "proc_owner" # --------------------------------------------------------------------------- # concise mode — pretty one-liner instead of the raw output dump # --------------------------------------------------------------------------- class TestConciseFormatter: def test_success_is_one_line_without_output(self): from gateway.run import _format_concise_process_notification text = _format_concise_process_notification( "proc_abc", "python3 scan_fleet.py --all", 0, "1300\n1400\n1500\n{...huge json...}", duration_seconds=754, ) assert text.startswith("✅ Background task finished") assert "scan_fleet.py" in text assert "12m 34s" in text # The raw output must NOT appear on success assert "1300" not in text assert "\n" not in text def test_failure_appends_short_tail(self): from gateway.run import _format_concise_process_notification out = "\n".join(f"line{i}" for i in range(50)) + "\nTraceback: boom" text = _format_concise_process_notification( "proc_abc", "make build", 2, out, ) assert text.startswith("❌ Background task failed") assert "exit 2" in text and "Traceback: boom" in text assert "rerun" in text # Only a short tail, not the whole output assert "line0" not in text def test_long_command_is_truncated(self): from gateway.run import _format_concise_process_notification text = _format_concise_process_notification( "proc_abc", "x" * 300, 0, "", ) assert "…" in text assert len(text) < 200 @pytest.mark.asyncio async def test_concise_mode_no_interim_output_updates(monkeypatch, tmp_path): """concise never pushes 'is still running~ New output' interim updates.""" import tools.process_registry as pr_module running = SimpleNamespace( output_buffer="chunk one\n", exited=False, exit_code=None, command="sleep 100", started_at=None, ) done = SimpleNamespace( output_buffer="chunk one\nchunk two\n", exited=True, exit_code=0, command="sleep 100", started_at=None, ) monkeypatch.setattr( pr_module, "process_registry", _FakeRegistry([running, done], consumed=False) ) async def _instant_sleep(*_a, **_kw): pass monkeypatch.setattr(asyncio, "sleep", _instant_sleep) runner = _build_runner(monkeypatch, tmp_path, "concise") adapter = runner.adapters[Platform.TELEGRAM] await runner._run_process_watcher(_watcher_dict()) # Exactly one send: the final concise message; no interim updates. adapter.send.assert_awaited_once() sent_text = adapter.send.await_args.args[1] assert "is still running" not in sent_text assert sent_text.startswith("✅ Background task finished") # --------------------------------------------------------------------------- # _parse_session_key helper # --------------------------------------------------------------------------- def test_parse_session_key_with_extra_parts(): """6th part in a group key may be a user_id, not a thread_id — omit it.""" result = _parse_session_key("agent:main:discord:group:chan123:thread456") assert result == {"platform": "discord", "chat_type": "group", "chat_id": "chan123"} def test_parse_session_key_named_profile(): """A named-profile namespace parses and is reported as ``profile``.""" result = _parse_session_key("agent:work:telegram:dm:123") assert result == { "profile": "work", "platform": "telegram", "chat_type": "dm", "chat_id": "123", } def test_parse_session_key_named_profile_thread_id(): """Thread-slot handling is identical for named-profile keys.""" result = _parse_session_key("agent:work:telegram:thread:100:42") assert result == { "profile": "work", "platform": "telegram", "chat_type": "thread", "chat_id": "100", "thread_id": "42", } def test_parse_session_key_named_profile_group_suffix_omitted(): """Group-suffix (user_id) stays omitted for named-profile keys too.""" result = _parse_session_key("agent:work:discord:group:chan1:user9") assert result == { "profile": "work", "platform": "discord", "chat_type": "group", "chat_id": "chan1", } def test_parse_session_key_main_shape_unchanged(): """``main`` keys keep their historical dict shape exactly (no ``profile`` key).""" result = _parse_session_key("agent:main:telegram:dm:123:42") assert result == { "platform": "telegram", "chat_type": "dm", "chat_id": "123", "thread_id": "42", } def test_parse_session_key_rejects_invalid_namespace(): """A namespace slot that cannot be a profile id still parses to None.""" assert _parse_session_key("agent:Bad_NS:telegram:dm:123") is None assert _parse_session_key("agent:main:telegram:dm") is None assert _parse_session_key("raw-session-id") is None def test_build_process_event_source_named_profile_key(monkeypatch, tmp_path): """A synthetic event keyed by a named-profile session resolves platform/chat and keeps the profile, instead of being unresolvable and dropped with a warning.""" runner = _build_runner(monkeypatch, tmp_path, "all") source = runner._build_process_event_source({ "session_id": "proc_watch", "session_key": "agent:work:telegram:dm:123", }) assert source is not None assert source.platform is Platform.TELEGRAM assert source.chat_id == "123" assert source.chat_type == "dm" assert source.profile == "work" # --------------------------------------------------------------------------- # api_server (stateless) wake routing — gateway/wake.py self-post path # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_inject_watch_notification_raw_session_key_self_posts(monkeypatch, tmp_path): """An event whose session_key is a RAW api_server session id (not an agent:main:... structured key) must wake the real session via the /v1/chat/completions self-post instead of being dropped for missing routing metadata.""" runner = _build_runner(monkeypatch, tmp_path, "all") api_adapter = SimpleNamespace( supports_async_delivery=False, handle_message=AdmittingHandler(), _host="127.0.0.1", _port=8642, _api_key="k", _model_name="m", ) runner.adapters[Platform.API_SERVER] = api_adapter posts = [] async def fake_self_post(adapter, *, text, session_id): posts.append({"text": text, "session_id": session_id}) import gateway.wake as wake_mod monkeypatch.setattr(wake_mod, "_self_post_chat_completion", fake_self_post) evt = { "session_id": "proc_watch", "session_key": "raw-hq-session-id", # no agent:main:... structure } result = await runner._inject_watch_notification("[SYSTEM: subagent finished]", evt) assert result is True api_adapter.handle_message.assert_not_awaited() # Same presentation contract as the push path: leading SYSTEM prefix intact, machine-origin # footer appended — this text becomes a role=user turn on the stateless surface too. assert len(posts) == 1 assert posts[0]["session_id"] == "raw-hq-session-id" assert posts[0]["text"].startswith("[SYSTEM: subagent finished]") assert posts[0]["text"].rstrip().endswith(INTERNAL_NOTIFICATION_FOOTER) @pytest.mark.asyncio async def test_inject_watch_notification_origin_session_id_wins(monkeypatch, tmp_path): """origin_session_id (stamped at dispatch time by async_delegation) takes precedence as the wake target.""" runner = _build_runner(monkeypatch, tmp_path, "all") api_adapter = SimpleNamespace( supports_async_delivery=False, handle_message=AdmittingHandler(), _host="127.0.0.1", _port=8642, _api_key="k", _model_name="m", ) runner.adapters[Platform.API_SERVER] = api_adapter posts = [] async def fake_self_post(adapter, *, text, session_id): posts.append(session_id) import gateway.wake as wake_mod monkeypatch.setattr(wake_mod, "_self_post_chat_completion", fake_self_post) evt = { "session_id": "proc_watch", "session_key": "", "origin_session_id": "raw-origin-sid", } result = await runner._inject_watch_notification("[SYSTEM: done]", evt) assert result is True assert posts == ["raw-origin-sid"] @pytest.mark.asyncio async def test_async_delegation_apiserver_persists_delivery_not_self_post( monkeypatch, tmp_path, ): """#85957: an async_delegation completion targeting a stateless api_server session must be persisted as a durable DELIVERY row — never self-POSTed to /v1/chat/completions as a new role=user prompt (which starts an unauthorized agent turn after the client-owned parent turn ended).""" runner = _build_runner(monkeypatch, tmp_path, "all") api_adapter = SimpleNamespace( supports_async_delivery=False, handle_message=AdmittingHandler(), _host="127.0.0.1", _port=8642, _api_key="k", _model_name="m", ) runner.adapters[Platform.API_SERVER] = api_adapter import gateway.wake as wake_mod posts = [] async def fake_self_post(adapter, *, text, session_id): posts.append(session_id) persisted = [] async def fake_persist(adapter, *, text, session_id, evt=None): persisted.append({"text": text, "session_id": session_id, "evt": evt}) monkeypatch.setattr(wake_mod, "_self_post_chat_completion", fake_self_post) monkeypatch.setattr(wake_mod, "persist_delegation_delivery", fake_persist) evt = { "type": "async_delegation", "delegation_id": "deleg_85957", "session_key": "raw-hq-session-id", # no agent:main:... structure "origin_session_id": "raw-hq-session-id", "status": "completed", } result = await runner._inject_watch_notification( "[ASYNC DELEGATION BATCH COMPLETE — deleg_85957]", evt, ) assert result is True assert posts == [] # the self-POST user-turn wake must NOT fire api_adapter.handle_message.assert_not_awaited() assert len(persisted) == 1 assert persisted[0]["session_id"] == "raw-hq-session-id" assert persisted[0]["evt"]["delegation_id"] == "deleg_85957" # Persist-only route stays verbatim: the client reads this row, no model turn is woken. assert persisted[0]["text"] == "[ASYNC DELEGATION BATCH COMPLETE — deleg_85957]" @pytest.mark.asyncio async def test_async_delegation_apiserver_persist_failure_is_retryable( monkeypatch, tmp_path, ): """A failed delivery persist returns False so the durable claim is released and the completion retried — never silently lost.""" runner = _build_runner(monkeypatch, tmp_path, "all") api_adapter = SimpleNamespace( supports_async_delivery=False, handle_message=AdmittingHandler(), _host="127.0.0.1", _port=8642, _api_key="k", _model_name="m", ) runner.adapters[Platform.API_SERVER] = api_adapter import gateway.wake as wake_mod async def fail_persist(adapter, *, text, session_id, evt=None): raise RuntimeError("state.db unavailable") monkeypatch.setattr(wake_mod, "persist_delegation_delivery", fail_persist) evt = { "type": "async_delegation", "delegation_id": "deleg_fail", "session_key": "raw-sid", } result = await runner._inject_watch_notification("[BATCH COMPLETE]", evt) assert result is False def test_gateway_drain_retains_and_formats_overflow_events(): """watch_overflow_* events must survive the gateway drain and render their summary — previously they were discarded at the drain (only watch_match/watch_disabled were retained) and had no formatter branch.""" import asyncio from gateway.run import ( _drain_gateway_watch_events, _format_gateway_process_notification, ) queue = asyncio.Queue() tripped = { "type": "watch_overflow_tripped", "message": "watch flood detected: 47 notifications suppressed for pattern 'ERROR'", "session_id": "proc_a1b2", } released = { "type": "watch_overflow_released", "message": "watch flood released: notifications resumed for pattern 'ERROR'", "session_id": "proc_a1b2", } queue.put_nowait(tripped) queue.put_nowait(released) retained = _drain_gateway_watch_events(queue) assert retained == [tripped, released] out_tripped = _format_gateway_process_notification(tripped) assert "47 notifications suppressed" in out_tripped assert "exit code" not in out_tripped out_released = _format_gateway_process_notification(released) assert "notifications resumed" in out_released assert "exit code" not in out_released @pytest.mark.asyncio async def test_raw_output_modes_are_human_facing(monkeypatch, tmp_path): """#54266: the chat-facing watcher messages (final in all/result/error, interim in all) carry a status header and the (ANSI-stripped) output, never the internal ``proc_*`` id or the bracketed ``[Background process …~ …]`` debug wrapper. Full output stays available via the process tool.""" import tools.process_registry as pr_module running = SimpleNamespace(output_buffer="\x1b[32mstep 1 ok\x1b[0m\n", exited=False, exit_code=None, command="make -j8 all", started_at=None) done = SimpleNamespace(output_buffer="\x1b[32mstep 1 ok\x1b[0m\n\x1b[31mlinker error\x1b[0m\n", exited=True, exit_code=2, command="make -j8 all", started_at=None) monkeypatch.setattr(pr_module, "process_registry", _FakeRegistry([running, done])) async def _instant_sleep(*_a, **_kw): pass monkeypatch.setattr(asyncio, "sleep", _instant_sleep) runner = _build_runner(monkeypatch, tmp_path, "all") adapter = runner.adapters[Platform.TELEGRAM] await runner._run_process_watcher(_watcher_dict(session_id="proc_deadbeef")) sent = [call.args[1] for call in adapter.send.await_args_list] assert len(sent) == 2 interim, final = sent assert interim.startswith("⏳ Background task still running") and "step 1 ok" in interim assert final.startswith("❌ Background task failed") and "exit 2" in final and "linker error" in final for text in sent: assert "proc_deadbeef" not in text and "[Background process" not in text and "~" not in text assert "\x1b[" not in text assert "make -j8 all" in text