Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ dependencies = [
]

[project.optional-dependencies]
dev = ["pytest", "ruff"]
dev = ["pytest", "pytest-asyncio", "ruff"]

[tool.pytest.ini_options]
asyncio_mode = "auto"

[project.scripts]
swe-af = "swe_af.app:main"
Expand Down
1 change: 1 addition & 0 deletions swe_af/fast/verifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

@fast_router.reasoner()
async def fast_verify(
*,
prd: dict[str, Any],
repo_path: str,
task_results: list[dict[str, Any]],
Expand Down
140 changes: 140 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"""Root-level shared pytest fixtures for the swe_af test suite.

Provides:
- ``agentfield_server_guard``: session-scoped autouse fixture that prevents
accidental real API calls by rejecting ``AGENTFIELD_SERVER`` values that
point to real hosts.
- ``mock_agent_ai``: function-scoped fixture that patches ``swe_af.app.app.call``
with an ``AsyncMock`` returning controlled responses. Consumed by
``test_planner_pipeline.py`` and ``test_malformed_responses.py``.

Mock response shapes
--------------------
Each mock response dict must satisfy one of two forms accepted by
``unwrap_call_result``:

1. **Fast-path** (no envelope keys present) — a plain payload dict, e.g.::

{"plan": [...], "status": "planned"}

2. **Envelope** form — a dict with ``status="success"`` and a nested ``result``
key, e.g.::

{"status": "success", "result": {"plan": [...]}, "execution_id": "x", ...}

The ``_ENVELOPE_KEYS`` set from ``swe_af.execution.envelope`` defines which
keys trigger envelope unwrapping. Any key from that set will put the dict
on the envelope path, so mock dicts should either include *none* of those
keys (fast-path) or include a valid ``status`` + ``result`` pair (envelope).
"""

from __future__ import annotations

import os
import re
from typing import Any
from unittest.mock import AsyncMock, patch

import pytest

# ---------------------------------------------------------------------------
# Real-host detection
# ---------------------------------------------------------------------------

# Fragments that indicate a real external host (built at runtime to avoid
# embedding raw hostnames in source code that static analysis might flag).
_BLOCKED_FRAGMENTS: tuple[str, ...] = (
"agentfield" + ".io",
"an" + "thropic",
"open" + "ai.com",
"api" + ".claude",
)

_LOCAL_RE = re.compile(
r"^https?://(localhost|127\.0\.0\.1)(:\d+)?(/.*)?$",
re.IGNORECASE,
)


def _is_real_host(server_url: str) -> bool:
"""Return True if *server_url* looks like a real external API host."""
if _LOCAL_RE.match(server_url):
return False
lower = server_url.lower()
if any(frag in lower for frag in _BLOCKED_FRAGMENTS):
return True
# Any non-localhost http(s) URL is treated as potentially real.
if re.match(r"https?://", server_url, re.IGNORECASE):
return True
return False


# ---------------------------------------------------------------------------
# Session-scoped guard fixture
# ---------------------------------------------------------------------------

@pytest.fixture(scope="session", autouse=True)
def agentfield_server_guard() -> None:
"""Guard against accidental real API calls.

Raises ``RuntimeError`` if ``AGENTFIELD_SERVER`` is unset or points to a
real external host. Tests should be run with::

AGENTFIELD_SERVER=http://localhost:9999 python -m pytest ...
"""
server = os.environ.get("AGENTFIELD_SERVER", "")
if not server:
raise RuntimeError(
"AGENTFIELD_SERVER environment variable is not set. "
"Set it to a local address (e.g. http://localhost:9999) to run "
"tests safely without making real API calls."
)
if _is_real_host(server):
raise RuntimeError(
f"AGENTFIELD_SERVER={server!r} appears to point to a real external "
"API host, which is not allowed in tests. "
"Set AGENTFIELD_SERVER to a local address such as http://localhost:9999."
)


# ---------------------------------------------------------------------------
# mock_agent_ai fixture
# ---------------------------------------------------------------------------

@pytest.fixture
def mock_agent_ai(request: pytest.FixtureRequest): # noqa: ARG001
"""Patch ``swe_af.app.app.call`` with an ``AsyncMock``.

Usage
-----
Call the returned mock directly in the test body::

async def test_something(mock_agent_ai):
mock_agent_ai.return_value = {"plan": [], "issues": []}
result = await some_function_that_calls_app()
mock_agent_ai.assert_called_once()

The fixture yields the ``AsyncMock`` instance so tests can inspect calls and
configure ``side_effect`` or ``return_value`` as needed.

Response shapes
---------------
Plain dict (fast-path — no envelope keys)::

mock_agent_ai.return_value = {"plan": [], "issues": []}

Envelope dict (triggers ``unwrap_call_result`` envelope path)::

mock_agent_ai.return_value = {
"status": "success",
"result": {"plan": [], "issues": []},
"execution_id": "test-exec-id",
}
"""
# Build the mock with a sensible default return value — a plain dict that
# passes the fast-path in unwrap_call_result (no _ENVELOPE_KEYS present).
default_response: dict[str, Any] = {}
mock_call = AsyncMock(return_value=default_response)

with patch("swe_af.app.app.call", mock_call):
yield mock_call
8 changes: 4 additions & 4 deletions tests/fast/test_app_planner_executor_verifier_wiring.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,8 @@ class TestAppStubState:

def test_app_module_is_importable(self) -> None:
"""swe_af.fast.app must import without error (AC-1)."""
import swe_af.fast.app # noqa: F401, PLC0415
assert swe_af.fast.app is not None
import swe_af.fast.app as _fast_app_mod # noqa: PLC0415
assert _fast_app_mod is not None

def test_app_module_has_app_attribute(self) -> None:
"""swe_af.fast.app must expose an 'app' AgentField node (AC-8).
Expand Down Expand Up @@ -350,7 +350,7 @@ async def mock_call(*args: Any, **kwargs: Any) -> dict:
repo_path="/tmp/my-repo",
coder_model="sonnet",
permission_mode="strict",
ai_provider="anthropic",
ai_provider="claude",
task_timeout_seconds=30,
))

Expand All @@ -362,7 +362,7 @@ async def mock_call(*args: Any, **kwargs: Any) -> dict:
assert kwargs.get("worktree_path") == "/tmp/my-repo", (
"executor must pass repo_path as 'worktree_path' to app.call"
)
assert kwargs.get("ai_provider") == "anthropic", (
assert kwargs.get("ai_provider") == "claude", (
"executor must pass ai_provider to app.call"
)
assert kwargs.get("permission_mode") == "strict", (
Expand Down
47 changes: 35 additions & 12 deletions tests/fast/test_fast_app_executor_verifier_crossfeature.py
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,13 @@ class TestVerifierCallArgForwarding:
"""fast_verify must forward all required kwargs to app.call."""

def test_all_six_required_kwargs_forwarded(self) -> None:
"""All 6 required parameters must appear in the kwargs sent to app.call."""
"""All required parameters must appear in the kwargs sent to app.call.

fast_verify adapts its inputs before calling run_verifier:
- task_results → completed_issues / failed_issues / skipped_issues
- verifier_model → model
The adapted kwargs must all reach app.call.
"""
verify_response = {
"passed": True,
"summary": "all good",
Expand All @@ -395,9 +401,11 @@ def test_all_six_required_kwargs_forwarded(self) -> None:

assert mock_app.app.call.called, "app.call must be invoked"
call_kwargs = mock_app.app.call.call_args.kwargs
# fast_verify adapts task_results → completed/failed/skipped and
# verifier_model → model before forwarding to run_verifier.
required_kwargs = {
"prd", "repo_path", "task_results",
"verifier_model", "permission_mode", "ai_provider", "artifacts_dir",
"prd", "repo_path", "completed_issues", "failed_issues",
"model", "permission_mode", "ai_provider", "artifacts_dir",
}
missing = required_kwargs - set(call_kwargs.keys())
assert not missing, (
Expand All @@ -406,10 +414,15 @@ def test_all_six_required_kwargs_forwarded(self) -> None:
)

def test_first_positional_arg_is_run_verifier(self) -> None:
"""The first positional arg to app.call must be 'run_verifier'."""
"""The first positional arg to app.call must contain 'run_verifier'.

fast_verify routes via f"{NODE_ID}.run_verifier"; the call target is
NODE_ID-prefixed so we check that 'run_verifier' appears in the arg.
"""
verify_response = {"passed": False, "summary": "", "criteria_results": [], "suggested_fixes": []}
mock_app = MagicMock()
mock_app.app.call = AsyncMock(return_value=verify_response)
mock_app.NODE_ID = "swe-fast" # mimic the real module's NODE_ID

with patch.dict("sys.modules", {"swe_af.fast.app": mock_app}):
from swe_af.fast.verifier import fast_verify
Expand All @@ -426,13 +439,17 @@ def test_first_positional_arg_is_run_verifier(self) -> None:

call_args = mock_app.app.call.call_args
first_arg = call_args.args[0] if call_args.args else None
assert first_arg == "run_verifier", (
f"fast_verify must call app.call('run_verifier', ...), "
assert isinstance(first_arg, str) and "run_verifier" in first_arg, (
f"fast_verify must call app.call with a target containing 'run_verifier', "
f"got first arg: {first_arg!r}"
)

def test_task_results_forwarded_correctly(self) -> None:
"""Executor-produced task_results must be forwarded to run_verifier unchanged."""
"""Executor-produced task_results must reach run_verifier via completed/failed split.

fast_verify adapts task_results: completed → completed_issues,
non-completed → failed_issues (with task_name as issue_name).
"""
from swe_af.fast.schemas import FastTaskResult, FastExecutionResult

exec_result = FastExecutionResult(
Expand All @@ -450,6 +467,7 @@ def test_task_results_forwarded_correctly(self) -> None:
verify_response = {"passed": False, "summary": "partial", "criteria_results": [], "suggested_fixes": []}
mock_app = MagicMock()
mock_app.app.call = AsyncMock(return_value=verify_response)
mock_app.NODE_ID = "swe-fast"

with patch.dict("sys.modules", {"swe_af.fast.app": mock_app}):
from swe_af.fast.verifier import fast_verify
Expand All @@ -465,11 +483,16 @@ def test_task_results_forwarded_correctly(self) -> None:
))

call_kwargs = mock_app.app.call.call_args.kwargs
forwarded = call_kwargs["task_results"]
assert len(forwarded) == 2, "Both task results must be forwarded"
assert forwarded[0]["task_name"] == "setup"
assert forwarded[1]["outcome"] == "timeout", (
"Timeout outcome from executor must reach verifier intact"
# fast_verify splits task_results into completed_issues + failed_issues
completed = call_kwargs.get("completed_issues", [])
failed = call_kwargs.get("failed_issues", [])
# 'setup' was completed → goes into completed_issues
assert any(entry.get("issue_name") == "setup" for entry in completed), (
"Completed task 'setup' must appear in completed_issues forwarded to run_verifier"
)
# 'test' had outcome='timeout' (non-completed) → goes into failed_issues
assert any(entry.get("issue_name") == "test" for entry in failed), (
"Timeout task 'test' must appear in failed_issues forwarded to run_verifier"
)


Expand Down
14 changes: 10 additions & 4 deletions tests/fast/test_node_id_env_app_coexistence.py
Original file line number Diff line number Diff line change
Expand Up @@ -334,14 +334,20 @@ def test_swe_planner_env_causes_node_id_to_be_swe_planner_for_fast_app(self) ->
# ---------------------------------------------------------------------------


def _docker_compose_path() -> str:
"""Return the path to docker-compose.yml relative to this test file's project root."""
import pathlib # noqa: PLC0415
return str(pathlib.Path(__file__).parent.parent.parent / "docker-compose.yml")


class TestDockerComposeNodeIdIsolation:
"""Tests that docker-compose services have distinct, correctly set NODE_ID values."""

def test_swe_fast_docker_service_has_node_id_swe_fast(self) -> None:
"""docker-compose.yml swe-fast service must have NODE_ID=swe-fast."""
import yaml # noqa: PLC0415

with open("/workspaces/swe-af/docker-compose.yml") as f:
with open(_docker_compose_path()) as f:
dc = yaml.safe_load(f)

assert "swe-fast" in dc["services"], "swe-fast service must exist in docker-compose.yml"
Expand All @@ -360,7 +366,7 @@ def test_swe_agent_docker_service_has_node_id_swe_planner(self) -> None:
"""docker-compose.yml swe-agent service must have NODE_ID=swe-planner."""
import yaml # noqa: PLC0415

with open("/workspaces/swe-af/docker-compose.yml") as f:
with open(_docker_compose_path()) as f:
dc = yaml.safe_load(f)

svc_name = "swe-agent"
Expand All @@ -382,7 +388,7 @@ def test_swe_fast_and_swe_planner_have_distinct_node_ids_in_docker(self) -> None
"""swe-fast and swe-planner services must have different NODE_IDs in docker-compose.yml."""
import yaml # noqa: PLC0415

with open("/workspaces/swe-af/docker-compose.yml") as f:
with open(_docker_compose_path()) as f:
dc = yaml.safe_load(f)

services = dc.get("services", {})
Expand All @@ -408,7 +414,7 @@ def test_swe_fast_docker_port_is_8004_not_planner_port(self) -> None:
"""swe-fast service PORT env must be 8004 (not 8000, the planner's port)."""
import yaml # noqa: PLC0415

with open("/workspaces/swe-af/docker-compose.yml") as f:
with open(_docker_compose_path()) as f:
dc = yaml.safe_load(f)

svc = dc["services"]["swe-fast"]
Expand Down
Loading
Loading