"""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
