"""Tests for the GitHub webhook fan-out helper. We do not exercise the full agent run here — that path now lives in the ChannelManager and is covered by integration tests. These tests verify the fan-out logic: bot-loop prevention, target extraction, registry lookup, trigger filtering, and that one InboundMessage with the right shape lands on the bus per matching binding. """ from __future__ import annotations from pathlib import Path import pytest import yaml from app.channels.message_bus import InboundMessage, MessageBus from app.gateway.github.dispatcher import fanout_event def _write_agent(base: Path, user_id: str, name: str, body: dict) -> Path: agent_dir = base / "users" / user_id / "agents" / name agent_dir.mkdir(parents=True, exist_ok=True) (agent_dir / "config.yaml").write_text(yaml.safe_dump(body), encoding="utf-8") return agent_dir @pytest.fixture() def base_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path)) from deerflow.config import paths as paths_module monkeypatch.setattr(paths_module, "_paths", None) # Each test uses a fresh tmp_path, so the registry's mtime cache from # a prior test would short-circuit the scan and return [] — drop it. from app.gateway.github.registry import _invalidate_cache _invalidate_cache() return tmp_path async def _drain(bus: MessageBus) -> list[InboundMessage]: out: list[InboundMessage] = [] while not bus.inbound_queue.empty(): out.append(await bus.get_inbound()) return out # --------------------------------------------------------------------------- # Bot-loop prevention — per-agent self-event gate # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_self_event_skips_the_owning_agent_only(base_dir: Path) -> None: """Events sent by an agent's own bot account skip THAT agent only. The reviewer (whose ``mention_login`` is ``llm-gateway-ai``) must not re-trigger on its own comment. The coder (different identity) should still fire on the same event if its triggers match. """ bus = MessageBus() _write_agent( base_dir, "default", "reviewer-llm-gateway", { "name": "reviewer-llm-gateway", "github": { "bindings": [ { "repo": "zhfeng/llm-gateway", "triggers": { "issue_comment": { "require_mention": True, "mention_login": "llm-gateway-ai", } }, } ], }, }, ) _write_agent( base_dir, "default", "coding-llm-gateway", { "name": "coding-llm-gateway", "github": { "bindings": [ { "repo": "zhfeng/llm-gateway", "triggers": { "issue_comment": { "require_mention": True, "mention_login": "coding-llm-gateway-ai", } }, } ], }, }, ) payload = { "action": "created", "issue": {"number": 5, "pull_request": {"url": "..."}}, "comment": { "body": "Following up @coding-llm-gateway-ai please address this.", "user": {"login": "llm-gateway-ai[bot]"}, }, "repository": {"full_name": "zhfeng/llm-gateway"}, "sender": {"login": "llm-gateway-ai[bot]"}, } result = await fanout_event(bus, "issue_comment", "del-self", payload) # Reviewer matched but skipped — sender is its own identity. assert "reviewer-llm-gateway" in result["matched_agents"] assert "reviewer-llm-gateway" not in result["fired_agents"] assert any(s["agent"] == "reviewer-llm-gateway" and s["reason"] == "self_event" for s in result["skipped"]) # Coder fires — same event, different self-identity. assert "coding-llm-gateway" in result["fired_agents"] messages = await _drain(bus) assert len(messages) == 1 assert messages[0].metadata["agent_name"] == "coding-llm-gateway" @pytest.mark.asyncio async def test_third_party_bot_events_are_not_skipped(base_dir: Path) -> None: """Events from other bots (Copilot, CodeRabbit, …) must reach the agents. The old all-bots short-circuit blocked these as a side effect; the new per-agent self-event gate only fires when ``sender.login`` matches the agent's own identity. """ bus = MessageBus() _write_agent( base_dir, "default", "reviewer-llm-gateway", { "name": "reviewer-llm-gateway", "github": { "bindings": [ { "repo": "zhfeng/llm-gateway", "triggers": { "issue_comment": { "require_mention": True, "mention_login": "llm-gateway-ai", } }, } ], }, }, ) payload = { "action": "created", "issue": {"number": 9, "pull_request": {"url": "..."}}, "comment": { "body": "Hey @llm-gateway-ai, here is my review.", "user": {"login": "Copilot"}, }, "repository": {"full_name": "zhfeng/llm-gateway"}, "sender": {"login": "Copilot", "type": "Bot"}, } result = await fanout_event(bus, "issue_comment", "del-copilot", payload) assert "reviewer-llm-gateway" in result["fired_agents"] assert result["skipped"] == [] messages = await _drain(bus) assert len(messages) == 1 assert messages[0].metadata["agent_name"] == "reviewer-llm-gateway" @pytest.mark.asyncio async def test_agent_name_is_only_a_fallback_when_no_explicit_identity_is_set(base_dir: Path) -> None: """``agent.name`` must NOT be in the self-identity set when ``bot_login`` or any ``mention_login`` is configured. Otherwise a real GitHub user whose login happens to equal an agent's directory name (``reviewer``, ``coder``, ``bot`` — all valid GitHub logins) would be silently dropped. The fallback only kicks in when the operator gave us nothing more specific. """ bus = MessageBus() # Agent named ``reviewer`` with an explicit bot_login that differs. _write_agent( base_dir, "default", "reviewer", { "name": "reviewer", "github": { "bot_login": "reviewer-app-bot", "bindings": [ { "repo": "a/b", "triggers": {"pull_request": {"actions": ["opened"]}}, } ], }, }, ) # Real human user with login ``reviewer`` opens a PR. payload = { "action": "opened", "pull_request": {"number": 1, "user": {"login": "reviewer"}, "title": "Fix typo", "body": ""}, "repository": {"full_name": "a/b"}, "sender": {"login": "reviewer"}, } result = await fanout_event(bus, "pull_request", "del-collision", payload) # The agent must fire — the human's login collides with the agent # directory name, but ``bot_login`` is the only true self-identity. assert result["fired_agents"] == ["reviewer"] assert result["skipped"] == [] @pytest.mark.asyncio async def test_agent_name_fallback_still_works_with_no_explicit_identity(base_dir: Path) -> None: """When no bot_login / mention_login is set, agent.name IS the self-identity. Preserves the safety net for the simplest config — an agent that just posts as ``@`` without configuring anything else still avoids re-triggering itself. """ bus = MessageBus() _write_agent( base_dir, "default", "solo-bot", { "name": "solo-bot", "github": { "bindings": [{"repo": "a/b", "triggers": {"pull_request": {"actions": ["opened"]}}}], }, }, ) payload = { "action": "opened", "pull_request": {"number": 2, "user": {"login": "solo-bot"}, "title": "x", "body": ""}, "repository": {"full_name": "a/b"}, "sender": {"login": "solo-bot[bot]"}, } result = await fanout_event(bus, "pull_request", "del-solo", payload) assert result["fired_agents"] == [] assert any(s["reason"] == "self_event" for s in result["skipped"]) # --------------------------------------------------------------------------- # Events without targets # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_ping_returns_no_target(base_dir: Path) -> None: bus = MessageBus() result = await fanout_event(bus, "ping", "del-1", {"zen": "x"}) assert result["matched_agents"] == [] assert any(r["reason"] == "no_target" for r in result["skipped"]) assert await _drain(bus) == [] # --------------------------------------------------------------------------- # No matching agents # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_no_matching_agents_returns_empty(base_dir: Path) -> None: bus = MessageBus() payload = { "action": "opened", "pull_request": {"number": 1, "user": {"login": "zhfeng"}}, "repository": {"full_name": "a/b"}, } result = await fanout_event(bus, "pull_request", "del-1", payload) assert result == {"matched_agents": [], "fired_agents": [], "skipped": []} assert await _drain(bus) == [] @pytest.mark.asyncio async def test_matching_agent_for_different_repo_skips(base_dir: Path) -> None: bus = MessageBus() _write_agent( base_dir, "default", "bot", { "name": "bot", "github": {"bindings": [{"repo": "other/repo"}]}, }, ) payload = { "action": "opened", "pull_request": {"number": 1, "user": {"login": "zhfeng"}}, "repository": {"full_name": "a/b"}, } result = await fanout_event(bus, "pull_request", "del-1", payload) assert result == {"matched_agents": [], "fired_agents": [], "skipped": []} assert await _drain(bus) == [] # --------------------------------------------------------------------------- # Happy paths # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_pull_request_opened_fires_and_publishes(base_dir: Path) -> None: bus = MessageBus() _write_agent( base_dir, "default", "reviewer", { "name": "reviewer", "github": { "installation_id": 1234, "bindings": [ { "repo": "zhfeng/llm-gateway", "triggers": {"pull_request": {"actions": ["opened"]}}, } ], }, }, ) payload = { "action": "opened", "pull_request": { "number": 7, "title": "Add feature", "user": {"login": "zhfeng"}, "body": "This is my change.", }, "repository": {"full_name": "zhfeng/llm-gateway"}, "sender": {"login": "zhfeng"}, } result = await fanout_event(bus, "pull_request", "del-abc", payload) assert result["matched_agents"] == ["reviewer"] assert result["fired_agents"] == ["reviewer"] assert result["skipped"] == [] messages = await _drain(bus) assert len(messages) == 1 msg = messages[0] assert msg.channel_name == "github" assert msg.chat_id == "zhfeng/llm-gateway" # topic_id pairs the PR number with the agent name so the # ChannelStore key separates per-agent threads on the same PR. assert msg.topic_id == "7:reviewer" assert msg.user_id == "zhfeng" assert msg.owner_user_id == "default" assert "Add feature" in msg.text assert msg.metadata["agent_name"] == "reviewer" gh = msg.metadata["github"] assert gh["repo"] == "zhfeng/llm-gateway" assert gh["number"] == 7 assert gh["installation_id"] == 1234 assert gh["event"] == "pull_request" assert gh["delivery_id"] == "del-abc" # recursion_limit defaults to None when the agent doesn't set one; # ChannelManager._resolve_run_params falls back to the channel default # (250) in that case. assert gh["recursion_limit"] is None # Deterministic thread id is surfaced as preferred_thread_id so the # manager's first-create path pins the LangGraph thread to it. assert msg.metadata["preferred_thread_id"] == gh["thread_id"] assert msg.metadata["preferred_thread_id"] # non-empty @pytest.mark.asyncio async def test_per_agent_recursion_limit_flows_through_metadata(base_dir: Path) -> None: """An agent's ``github.recursion_limit`` is ferried to ChannelManager. This is the bridge between the YAML config field and the actual ``run_config["recursion_limit"]`` the manager hands LangGraph: the dispatcher reads it from the binding at fanout time and stashes it in ``msg.metadata["github"]["recursion_limit"]`` so the manager doesn't need to re-load the AgentConfig per delivery. """ bus = MessageBus() _write_agent( base_dir, "default", "refactorer", { "name": "refactorer", "github": { "installation_id": 1234, "recursion_limit": 500, "bindings": [ { "repo": "owner/big-repo", "triggers": {"pull_request": {"actions": ["opened"]}}, } ], }, }, ) payload = { "action": "opened", "pull_request": {"number": 1, "user": {"login": "zhfeng"}, "title": "x", "body": ""}, "repository": {"full_name": "owner/big-repo"}, "sender": {"login": "zhfeng"}, } await fanout_event(bus, "pull_request", "del-rl", payload) messages = await _drain(bus) assert len(messages) == 1 assert messages[0].metadata["github"]["recursion_limit"] == 500 @pytest.mark.asyncio async def test_issue_comment_with_mention_fires(base_dir: Path) -> None: bus = MessageBus() _write_agent( base_dir, "default", "assistant", { "name": "assistant", "github": { "bindings": [ { "repo": "zhfeng/llm-gateway", "triggers": { "issue_comment": { "require_mention": True, "mention_login": "assistant", } }, } ], }, }, ) payload = { "action": "created", "issue": {"number": 11, "pull_request": {"url": "..."}}, "comment": { "body": "Hey @assistant can you review this?", "user": {"login": "zhfeng"}, }, "repository": {"full_name": "zhfeng/llm-gateway"}, "sender": {"login": "zhfeng"}, } result = await fanout_event(bus, "issue_comment", "del-def", payload) assert "assistant" in result["fired_agents"] messages = await _drain(bus) assert len(messages) == 1 assert messages[0].metadata["agent_name"] == "assistant" @pytest.mark.asyncio async def test_issue_comment_without_mention_skipped(base_dir: Path) -> None: bus = MessageBus() _write_agent( base_dir, "default", "bot", { "name": "bot", "github": { "bindings": [ { "repo": "a/b", "triggers": {"issue_comment": {"require_mention": True}}, } ], }, }, ) payload = { "action": "created", "issue": {"number": 2, "pull_request": {"url": "..."}}, "comment": {"body": "general chat without mention", "user": {"login": "alice"}}, "repository": {"full_name": "a/b"}, "sender": {"login": "alice"}, } result = await fanout_event(bus, "issue_comment", "del-xyz", payload) assert result["fired_agents"] == [] assert len(result["skipped"]) == 1 assert "mention" in result["skipped"][0]["reason"] assert await _drain(bus) == [] @pytest.mark.asyncio async def test_require_mention_uses_bot_login_when_trigger_omits_mention_login(base_dir: Path) -> None: """Regression pin for willem-bd's finding #2 on PR #3754. Mention gating must default to ``github.bot_login`` (the App's @-handle, the same identity used by the self-event gate), not to the agent's directory name. Previously this fell back to ``agent.name``, so an operator who set ``github.bot_login: deerflow-bot`` on an agent whose directory was named ``coder`` would see every legitimate ``@deerflow-bot`` mention rejected with ``mention required for @coder`` — the two gates disagreed on identity. """ bus = MessageBus() _write_agent( base_dir, "default", "coder", # agent directory name differs from the App's bot handle { "name": "coder", "github": { "bot_login": "deerflow-bot", "bindings": [ { "repo": "a/b", # No per-trigger mention_login override — the # default fallback path is what is under test. "triggers": {"issue_comment": {"require_mention": True}}, } ], }, }, ) # Mentioning the configured bot_login should fire the agent. mention_payload = { "action": "created", "issue": {"number": 7, "pull_request": {"url": "..."}}, "comment": {"body": "hey @deerflow-bot please look at this", "user": {"login": "alice"}}, "repository": {"full_name": "a/b"}, "sender": {"login": "alice"}, } result = await fanout_event(bus, "issue_comment", "del-bot-mention", mention_payload) assert result["fired_agents"] == ["coder"], result drained = await _drain(bus) assert len(drained) == 1 # Mentioning the agent's directory name (the previous fallback) must # NOT fire — that was the exact misbehaviour reported. bus_2 = MessageBus() dirname_payload = { **mention_payload, "comment": {"body": "hey @coder look at this", "user": {"login": "alice"}}, } result_2 = await fanout_event(bus_2, "issue_comment", "del-dir-mention", dirname_payload) assert result_2["fired_agents"] == [], result_2 assert len(result_2["skipped"]) == 1 assert "mention required for @deerflow-bot" in result_2["skipped"][0]["reason"] @pytest.mark.asyncio async def test_require_mention_falls_back_to_agent_name_when_no_bot_login(base_dir: Path) -> None: """When ``github.bot_login`` is unset, the agent's directory name is the fallback — matching the precedence the self-event gate uses. This preserves the existing behaviour for agents that never configured a distinct App identity. """ bus = MessageBus() _write_agent( base_dir, "default", "assistant", { "name": "assistant", "github": { # No bot_login here. "bindings": [ { "repo": "a/b", "triggers": {"issue_comment": {"require_mention": True}}, } ], }, }, ) payload = { "action": "created", "issue": {"number": 9, "pull_request": {"url": "..."}}, "comment": {"body": "hey @assistant please look", "user": {"login": "alice"}}, "repository": {"full_name": "a/b"}, "sender": {"login": "alice"}, } result = await fanout_event(bus, "issue_comment", "del-fallback", payload) assert result["fired_agents"] == ["assistant"], result # --------------------------------------------------------------------------- # Operator-set default mention login (R8 — channels.github.default_mention_login) # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_operator_default_mention_login_used_when_agent_omits_bot_login(base_dir: Path) -> None: """Regression pin for willem-bd's R8 on PR #3754. CLAUDE.md documents ``channels.github.default_mention_login`` as the global default for ``require_mention`` triggers. When neither the trigger nor the agent's ``github.bot_login`` sets a handle, this operator-set default must be used as the fallback — *before* the agent's directory name. Previously the chain skipped this step entirely, so an operator setting ``default_mention_login: deerflow-bot`` saw mentions still gated on ``@coder`` (the agent's directory name). """ bus = MessageBus() _write_agent( base_dir, "default", "coder", { "name": "coder", "github": { # No bot_login — exercises the operator-default branch. "bindings": [ {"repo": "a/b", "triggers": {"issue_comment": {"require_mention": True}}}, ], }, }, ) # @deerflow-bot (the configured operator default) must fire. fire_payload = { "action": "created", "issue": {"number": 7, "pull_request": {"url": "..."}}, "comment": {"body": "hey @deerflow-bot please look", "user": {"login": "alice"}}, "repository": {"full_name": "a/b"}, "sender": {"login": "alice"}, } result = await fanout_event( bus, "issue_comment", "del-opdef-fire", fire_payload, operator_default_mention_login="deerflow-bot", ) assert result["fired_agents"] == ["coder"], result # @coder (the agent-name last-resort fallback) must NOT fire when an # operator default is configured — the operator's intent wins. bus_2 = MessageBus() skip_payload = { **fire_payload, "comment": {"body": "hey @coder look", "user": {"login": "alice"}}, } result_2 = await fanout_event( bus_2, "issue_comment", "del-opdef-skip", skip_payload, operator_default_mention_login="deerflow-bot", ) assert result_2["fired_agents"] == [], result_2 assert "mention required for @deerflow-bot" in result_2["skipped"][0]["reason"] @pytest.mark.asyncio async def test_agent_bot_login_outranks_operator_default(base_dir: Path) -> None: """Per-agent ``github.bot_login`` outranks the global operator default. An agent that opts into its own App identity is the authority for its own mention handle. The operator default is the *fallback* for agents that haven't configured one — it must not override the per-agent setting (otherwise distinct App-per-agent deployments break). """ bus = MessageBus() _write_agent( base_dir, "default", "reviewer", { "name": "reviewer", "github": { "bot_login": "reviewer-bot", # per-agent identity "bindings": [ {"repo": "a/b", "triggers": {"issue_comment": {"require_mention": True}}}, ], }, }, ) # The operator default is set, but per-agent bot_login wins. fire_payload = { "action": "created", "issue": {"number": 7, "pull_request": {"url": "..."}}, "comment": {"body": "hey @reviewer-bot please review", "user": {"login": "alice"}}, "repository": {"full_name": "a/b"}, "sender": {"login": "alice"}, } result = await fanout_event( bus, "issue_comment", "del-perbot", fire_payload, operator_default_mention_login="deerflow-bot", ) assert result["fired_agents"] == ["reviewer"], result # Mentioning the operator default while the agent has its own bot_login # must NOT fire — the operator default is irrelevant here. bus_2 = MessageBus() miss_payload = { **fire_payload, "comment": {"body": "hey @deerflow-bot review please", "user": {"login": "alice"}}, } result_2 = await fanout_event( bus_2, "issue_comment", "del-perbot-skip", miss_payload, operator_default_mention_login="deerflow-bot", ) assert result_2["fired_agents"] == [], result_2 assert "mention required for @reviewer-bot" in result_2["skipped"][0]["reason"] @pytest.mark.asyncio async def test_trigger_mention_login_outranks_operator_default(base_dir: Path) -> None: """Per-trigger ``mention_login`` is the most specific override — it must outrank both ``github.bot_login`` and the operator default. """ bus = MessageBus() _write_agent( base_dir, "default", "agent-x", { "name": "agent-x", "github": { "bot_login": "agent-x-bot", "bindings": [ { "repo": "a/b", # Per-trigger override — most specific wins. "triggers": { "issue_comment": { "require_mention": True, "mention_login": "trigger-handle", } }, }, ], }, }, ) payload = { "action": "created", "issue": {"number": 7, "pull_request": {"url": "..."}}, "comment": {"body": "hey @trigger-handle please", "user": {"login": "alice"}}, "repository": {"full_name": "a/b"}, "sender": {"login": "alice"}, } result = await fanout_event( bus, "issue_comment", "del-trigger-override", payload, operator_default_mention_login="deerflow-bot", ) assert result["fired_agents"] == ["agent-x"], result @pytest.mark.asyncio async def test_operator_default_blank_string_treated_as_none(base_dir: Path) -> None: """An empty / whitespace-only operator default must not silently substitute itself as the mention handle. A misconfigured ``channels.github.default_mention_login: ""`` would otherwise yield ``require_mention`` gating on the empty string, which silently lets every mention through (or rejects everything, depending on how downstream logic treats it). Whitespace is stripped and falsy values fall through to the existing ``agent.name`` fallback — preserving the pre-R8 contract for misconfigured installs. """ bus = MessageBus() _write_agent( base_dir, "default", "assistant", { "name": "assistant", "github": { "bindings": [ {"repo": "a/b", "triggers": {"issue_comment": {"require_mention": True}}}, ], }, }, ) payload = { "action": "created", "issue": {"number": 7, "pull_request": {"url": "..."}}, "comment": {"body": "hey @assistant please", "user": {"login": "alice"}}, "repository": {"full_name": "a/b"}, "sender": {"login": "alice"}, } # Whitespace-only operator default → fall through to agent.name. result = await fanout_event( bus, "issue_comment", "del-blank-opdef", payload, operator_default_mention_login=" ", ) assert result["fired_agents"] == ["assistant"], result @pytest.mark.asyncio async def test_bot_login_whitespace_only_treated_as_none(base_dir: Path) -> None: """A whitespace-only ``github.bot_login`` must not silently become the mention-gating handle. AGENTS.md documents the whole ``require_mention`` precedence chain (``trigger.mention_login`` -> ``github.bot_login`` -> ``channels.github.default_mention_login`` -> ``agent.name``) as treating whitespace-only defaults as unset. A misconfigured ``bot_login: " "`` (e.g. a YAML templating slip) is truthy in Python, so an unstripped ``github.bot_login or operator_default or agent.name`` never falls through to the working ``agent.name`` fallback — every legitimate ``@assistant`` mention is silently rejected and the trigger can never fire again until an operator notices and fixes the typo. """ bus = MessageBus() _write_agent( base_dir, "default", "assistant", { "name": "assistant", "github": { "bot_login": " ", # whitespace-only — must be treated as unset "bindings": [ {"repo": "a/b", "triggers": {"issue_comment": {"require_mention": True}}}, ], }, }, ) payload = { "action": "created", "issue": {"number": 7, "pull_request": {"url": "..."}}, "comment": {"body": "hey @assistant please", "user": {"login": "alice"}}, "repository": {"full_name": "a/b"}, "sender": {"login": "alice"}, } # Whitespace-only bot_login → falls through to the agent.name fallback. result = await fanout_event(bus, "issue_comment", "del-blank-bot-login", payload) assert result["fired_agents"] == ["assistant"], result @pytest.mark.asyncio async def test_trigger_mention_login_whitespace_only_treated_as_none(base_dir: Path) -> None: """A whitespace-only per-trigger ``mention_login`` must not silently become the mention-gating handle either. Same contract as ``test_bot_login_whitespace_only_treated_as_none``, one link higher in the precedence chain: ``event_should_fire`` reads ``trigger.mention_login`` first. A misconfigured ``mention_login: " "`` is truthy, so an unstripped ``trigger.mention_login or default_mention_login`` never falls through to the agent's real ``github.bot_login`` handle. """ bus = MessageBus() _write_agent( base_dir, "default", "coder", { "name": "coder", "github": { "bot_login": "deerflow-bot", # the real, working fallback handle "bindings": [ { "repo": "a/b", "triggers": { "issue_comment": { "require_mention": True, "mention_login": " ", # whitespace-only — must be treated as unset } }, }, ], }, }, ) payload = { "action": "created", "issue": {"number": 7, "pull_request": {"url": "..."}}, "comment": {"body": "hey @deerflow-bot please look", "user": {"login": "alice"}}, "repository": {"full_name": "a/b"}, "sender": {"login": "alice"}, } # Whitespace-only trigger.mention_login → falls through to github.bot_login. result = await fanout_event(bus, "issue_comment", "del-blank-trigger-mention", payload) assert result["fired_agents"] == ["coder"], result # --------------------------------------------------------------------------- # Multiple agents # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_multiple_agents_on_same_repo_event(base_dir: Path) -> None: bus = MessageBus() for n in ("alpha", "beta"): _write_agent( base_dir, "default", n, { "name": n, "github": { "bindings": [ {"repo": "a/b", "triggers": {"pull_request": {}}}, ], }, }, ) payload = { "action": "opened", "pull_request": {"number": 1, "user": {"login": "x"}}, "repository": {"full_name": "a/b"}, "sender": {"login": "x"}, } result = await fanout_event(bus, "pull_request", "del-multi", payload) assert sorted(result["fired_agents"]) == ["alpha", "beta"] messages = await _drain(bus) assert sorted(m.metadata["agent_name"] for m in messages) == ["alpha", "beta"] # --------------------------------------------------------------------------- # Registry scan stays off the event loop # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_fanout_offloads_registry_scan_to_thread(base_dir: Path, monkeypatch: pytest.MonkeyPatch) -> None: """The registry rebuild must not block the asyncio event loop. A slow filesystem (NFS, container volume, large agent set) would otherwise push the webhook past GitHub's 10s delivery timeout. We offload via ``asyncio.to_thread``; verify by intercepting ``build_github_agent_registry`` and asserting it runs on a non-main thread. """ import threading main_thread = threading.get_ident() seen_threads: list[int] = [] from app.gateway.github import dispatcher as dispatcher_module real = dispatcher_module.build_github_agent_registry def _spy() -> dict: seen_threads.append(threading.get_ident()) return real() monkeypatch.setattr(dispatcher_module, "build_github_agent_registry", _spy) _write_agent( base_dir, "default", "agent-x", {"name": "agent-x", "github": {"bindings": [{"repo": "a/b", "triggers": {"pull_request": {"actions": ["opened"]}}}]}}, ) payload = { "action": "opened", "pull_request": {"number": 1, "user": {"login": "u"}, "title": "x", "body": ""}, "repository": {"full_name": "a/b"}, "sender": {"login": "u"}, } await fanout_event(MessageBus(), "pull_request", "del-thread", payload) assert seen_threads, "registry scan was not invoked" assert main_thread not in seen_threads, "registry scan must run off the event loop" # --------------------------------------------------------------------------- # Per-agent thread separation # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_coder_and_reviewer_on_same_pr_get_distinct_threads(base_dir: Path) -> None: """Two agents bound to the same PR must NOT share a LangGraph thread. Headline guarantee of the per-agent thread design. If both agents landed on one thread: * ``multitask_strategy="reject"`` would silently drop one run on every dual-mention (``@coder @reviewer please ...``). * Their message histories and sandbox state would interleave. * Cancelling one would interrupt the other. Verify here that ``preferred_thread_id`` and ``topic_id`` both diverge between bindings on the same ``(repo, number)``. ``topic_id`` is the ChannelStore key component, so the manager will look up a different cached thread per agent and pin each to its own deterministic UUID5 on first arrival. """ bus = MessageBus() for n, login in (("coder", "coder-bot"), ("reviewer", "reviewer-bot")): _write_agent( base_dir, "default", n, { "name": n, "github": { "bot_login": login, "bindings": [ { "repo": "a/b", "triggers": {"pull_request": {"actions": ["opened"]}}, } ], }, }, ) payload = { "action": "opened", "pull_request": {"number": 7, "user": {"login": "alice"}, "title": "x", "body": ""}, "repository": {"full_name": "a/b"}, "sender": {"login": "alice"}, } result = await fanout_event(bus, "pull_request", "del-split", payload) assert sorted(result["fired_agents"]) == ["coder", "reviewer"] messages = await _drain(bus) assert len(messages) == 2 by_agent = {m.metadata["agent_name"]: m for m in messages} coder, reviewer = by_agent["coder"], by_agent["reviewer"] # Distinct LangGraph threads. assert coder.metadata["preferred_thread_id"] != reviewer.metadata["preferred_thread_id"] # Distinct store rows (manager keys on (channel_name, chat_id, topic_id)). assert coder.topic_id != reviewer.topic_id assert coder.topic_id == "7:coder" assert reviewer.topic_id == "7:reviewer" # Each metadata mirrors its own thread id. assert coder.metadata["preferred_thread_id"] == coder.metadata["github"]["thread_id"] assert reviewer.metadata["preferred_thread_id"] == reviewer.metadata["github"]["thread_id"]