Files
2026-07-13 11:59:58 +08:00

1037 lines
37 KiB
Python

"""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 ``@<agent-name>`` 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"]