Background-process completions and watch events re-enter the gateway as synthetic MessageEvent(internal=True) turns carrying the id of the message that STARTED the process (captured at spawn time via HERMES_SESSION_MESSAGE_ID, persisted as watcher_message_id, replayed on the queued event). By delivery time that message is old news: the user has typically continued elsewhere, and _reply_anchor_for_event made the finished job's reply quote it on every reply-anchoring platform — on Discord the completion visibly answered a stale DM message from a different topic (#52694). The synthetic event is not a reply to the trigger message. Drop the id from the event and from the restored origin source (the same strip run_goals._synthetic_prompt_event applies to goal/loop prompts), keep it in event metadata as original_trigger_message_id for debugging, and leave routing untouched: thread/topic lanes carry thread_id and the anchor-less synthetic-send branches are already covered (#87051). Fixes #52694 Co-authored-by: Hermes Agent <agent@hermes.local>
818 lines
30 KiB
Python
818 lines
30 KiB
Python
"""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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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"
|
|
|
|
|
|
@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()
|
|
assert posts == [
|
|
{"text": "[SYSTEM: subagent finished]", "session_id": "raw-hq-session-id"}
|
|
]
|
|
|
|
|
|
@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"
|
|
|
|
|
|
@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
|