Files
zeekayandHanzo Dev 56ff389fc7 Every SDK calls /v1/, and they agree on the addresses
A sweep in May moved the Python SDK's paths to /v1/ per the estate rule
and recorded that the Go SDK already matched. It did not: the Go and
TypeScript SDKs still sent /api/v1/ everywhere, so the three clients for
one control plane disagreed about where it is. Python's own client was
missed too, which is why twelve of its tests failed the moment its suite
could be collected again.

Where they disagreed on more than the prefix, the majority decides. Go
alone posted a vector to the collection address rather than
/memory/vector/set, and alone deleted one by a keyed DELETE rather than
posting to /memory/vector/delete — no server serving the other two would
have answered either.

Notes now go to /v1/ui/executions/note from all three, built by
appending rather than by rewriting the first version segment found.

openrouter.ai/api/v1 is a third party's own address and is untouched.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-11 04:10:09 -07:00

716 lines
23 KiB
Python

# Pytest configuration and fixtures for Hanzo Agents SDK tests
"""
Shared fixtures used by the actively supported open-source test suite.
The helpers here focus on deterministic behaviour (frozen time, env patching,
optional HTTP stubs) without enforcing heavy global mocks so that individual
tests can choose their own strategy.
"""
from __future__ import annotations
import json
import os
import sys
import types
import uuid
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional
import pytest
import respx
import responses as responses_lib
from freezegun import freeze_time
from hanzo_agents.agent import Agent
from hanzo_agents.types import AIConfig, MemoryConfig
# Optional imports guarded for test envs
try:
from pytest_socket import (
disable_socket,
enable_socket,
socket_allow_unix_socket,
) # type: ignore
except Exception: # pragma: no cover
disable_socket = None # type: ignore
enable_socket = None # type: ignore
socket_allow_unix_socket = None # type: ignore
try:
import httpx
except Exception: # pragma: no cover
httpx = None # type: ignore[assignment]
try:
from fastapi import FastAPI
from fastapi.responses import JSONResponse
except Exception: # pragma: no cover
FastAPI = None # type: ignore[assignment]
JSONResponse = None # type: ignore[assignment]
def _network_allowed(node: "pytest.Node") -> bool:
return bool(
node.get_closest_marker("integration") or node.get_closest_marker("mcp")
)
@pytest.fixture(autouse=True)
def _no_network_by_default(request):
yield
# HTTPX mocking via respx (assert all mocked by default)
@pytest.fixture(autouse=True)
def _respx_mock_by_default(request):
yield
# requests mocking via responses (non-strict by default; socket is already disabled)
@pytest.fixture(autouse=True)
def _responses_mock_by_default(request):
if request.node.get_closest_marker("integration"):
yield
return
responses_lib.start()
try:
yield
finally:
responses_lib.stop()
responses_lib.reset()
# Deterministic time helper (opt-in)
@pytest.fixture
def frozen_time():
"""
Freeze time to a fixed instant within this test's scope.
"""
with freeze_time("2024-01-01T00:00:00Z"):
yield
# ---------------------------- 1) Environment Patch Fixture ----------------------------
class _EnvPatcher:
"""
Context-aware environment patcher that safely sets/unsets environment variables
and restores the original state after use.
Usage:
def test_env(env_patch):
env_patch.setenv("AGENT_CALLBACK_URL", "http://agent:8000")
# ... test ...
# Automatically restored after the test
# As a context manager for sub-scopes:
with env_patch.context():
env_patch.setenv("RAILWAY_SERVICE_NAME", "my-svc")
# sub-scope envs restored on exit
"""
def __init__(self):
self._originals: Dict[str, Optional[str]] = {}
self._to_del_after: set[str] = set()
def _record(self, key: str):
if key not in self._originals:
self._originals[key] = os.environ.get(key)
def setenv(self, key: str, value: str) -> None:
self._record(key)
os.environ[key] = value
def setdefault(self, key: str, value: str) -> None:
self._record(key)
if key not in os.environ:
os.environ[key] = value
def unset(self, key: str) -> None:
self._record(key)
if key in os.environ:
del os.environ[key]
self._to_del_after.add(key)
def apply(self, mapping: Dict[str, Optional[str]]) -> None:
"""
Apply a mapping: value=None means unset.
"""
for k, v in mapping.items():
if v is None:
self.unset(k)
else:
self.setenv(k, v)
def restore(self) -> None:
for key, original in self._originals.items():
if original is None:
if key in os.environ:
del os.environ[key]
else:
os.environ[key] = original
self._originals.clear()
self._to_del_after.clear()
def context(self):
"""
Local context manager for nested scopes.
"""
patcher = self
class _Ctx:
def __enter__(self):
return patcher
def __exit__(self, exc_type, exc, tb):
patcher.restore()
return _Ctx()
@pytest.fixture
def env_patch():
"""
Provides a safe, test-scoped environment variable patcher.
Example:
def test_callback_resolution(env_patch):
env_patch.setenv("AGENT_CALLBACK_URL", "http://host:9999")
# ... construct Agent and assert base_url resolution ...
"""
patcher = _EnvPatcher()
try:
yield patcher
finally:
patcher.restore()
# ---------------------------- 2) LLM Mock Fixture ----------------------------
class _FakeLLMModule(types.ModuleType):
"""
Minimal fake of the 'llm' module providing:
- get_model_info(model)
- token_counter(messages=..., model=...)
- completion(**kwargs)
- acompletion(**kwargs) (async)
You can inject custom behaviors via the provided configuration methods.
"""
class _ModelInfo:
def __init__(
self, max_tokens: int = 131072, max_output_tokens: Optional[int] = None
):
self.max_tokens = max_tokens
self.max_output_tokens = max_output_tokens
def __init__(self, name="llm"):
super().__init__(name)
# Defaults
self._model_info: Dict[str, Any] = {}
self._completion_response: Any = {"choices": [{"message": {"content": "ok"}}]}
self._completion_error: Optional[Exception] = None
self._token_counter_impl: Optional[Callable[..., int]] = None
# API: get_model_info
def get_model_info(self, model: str):
info = self._model_info.get(model)
if info is None:
return self._ModelInfo() # default
return info
# API: token_counter
def token_counter(
self, messages: Any = None, model: Optional[str] = None, **kwargs
) -> int:
if self._token_counter_impl:
return self._token_counter_impl(messages=messages, model=model, **kwargs)
# naive default: chars // 4
text = ""
if isinstance(messages, list):
for m in messages:
c = m.get("content")
if isinstance(c, str):
text += c
elif isinstance(c, list):
for item in c:
if isinstance(item, dict) and item.get("type") == "text":
text += str(item.get("text", ""))
return max(1, len(text) // 4)
# API: completion (sync)
def completion(self, **kwargs):
if self._completion_error:
raise self._completion_error
return self._completion_response
# API: acompletion (async)
async def acompletion(self, **kwargs):
if self._completion_error:
raise self._completion_error
# mimic async latency slightly in debug contexts
return self._completion_response
# Config helpers
def _set_model_info(
self,
model: str,
max_tokens: int = 131072,
max_output_tokens: Optional[int] = None,
):
self._model_info[model] = self._ModelInfo(
max_tokens=max_tokens, max_output_tokens=max_output_tokens
)
def _set_completion_response(self, response: Any):
self._completion_response = response
self._completion_error = None
def _set_completion_error(self, exc: Exception):
self._completion_error = exc
def _set_token_counter(self, impl: Callable[..., int]):
self._token_counter_impl = impl
@dataclass
class LLMMockController:
"""
Controller for configuring the fake LLM behavior.
Methods:
set_model_info(model, max_tokens, max_output_tokens)
set_completion_response(response_dict)
set_completion_error(exception)
set_token_counter(func)
"""
module: _FakeLLMModule
def set_model_info(
self,
model: str,
max_tokens: int = 131072,
max_output_tokens: Optional[int] = None,
):
self.module._set_model_info(model, max_tokens, max_output_tokens)
def set_completion_response(self, response: Any):
self.module._set_completion_response(response)
def set_completion_error(self, exc: Exception):
self.module._set_completion_error(exc)
def set_token_counter(self, func: Callable[..., int]):
self.module._set_token_counter(func)
@pytest.fixture
def llm_mock(monkeypatch) -> LLMMockController:
"""
Provides a fake 'llm' module injected into sys.modules, covering:
- get_model_info
- token_counter
- completion / acompletion
Example:
def test_model_limits_caching(llm_mock):
llm_mock.set_model_info("openai/gpt-4o-mini", max_tokens=128000, max_output_tokens=4096)
# ... call ai_config.get_model_limits() and assert ...
async def test_ai_completion_success(llm_mock):
llm_mock.set_completion_response({"choices": [{"message": {"content": "hello"}}]})
# ... call Agent.ai() and assert ...
def test_ai_completion_error(llm_mock):
llm_mock.set_completion_error(RuntimeError("LLM down"))
# ... exercise error path/fallbacks ...
"""
fake = _FakeLLMModule()
# If real llm is present, we still shadow it within this test scope
monkeypatch.setitem(sys.modules, "llm", fake)
# Also shadow the symbol imported as: from llm import completion as llm_completion
def _completion_alias(**kwargs):
return fake.completion(**kwargs)
monkeypatch.setitem(sys.modules, "llm.completion", _completion_alias) # type: ignore
return LLMMockController(module=fake)
class HanzoAgentsHTTPMocks:
"""
Helper wrapper that registers common Hanzo Agents server endpoints on both:
- httpx (via respx)
- requests (via responses)
Defaults to http://localhost:8080 api base.
Example:
def test_register_success(http_mocks):
http_mocks.mock_register_node(status=201)
# ... call client.register_agent(...) path or register_node() ...
async def test_execute_ok(http_mocks):
http_mocks.mock_execute("node.reasoner", json={"result": {"ok": True}})
# ... await client.execute("node.reasoner", {...}) ...
"""
def __init__(self, base_url: str = "http://localhost:8080"):
self.base_url = base_url.rstrip("/")
self.api_base = f"{self.base_url}/v1"
# ----- Nodes -----
def mock_register_node(
self, status: int = 201, json: Optional[Dict[str, Any]] = None
):
url = f"{self.api_base}/nodes/register"
# httpx mock
respx.post(url).mock(
return_value=httpx.Response(status_code=status, json=json or {})
) # type: ignore
# requests mock
responses_lib.add(responses_lib.POST, url, json=json or {}, status=status)
def mock_update_health(
self, node_id: str, status: int = 200, json: Optional[Dict[str, Any]] = None
):
url = f"{self.api_base}/nodes/{node_id}/health"
respx.put(url).mock(
return_value=httpx.Response(status_code=status, json=json or {})
) # type: ignore
responses_lib.add(responses_lib.PUT, url, json=json or {}, status=status)
def mock_heartbeat(self, node_id: str, status: int = 200):
url = f"{self.api_base}/nodes/{node_id}/heartbeat"
respx.post(url).mock(
return_value=httpx.Response(status_code=status, json={"ok": True})
) # type: ignore
responses_lib.add(responses_lib.POST, url, json={"ok": True}, status=status)
# ----- Execute -----
def mock_execute(
self,
target: str,
status: int = 200,
json: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, str]] = None,
):
url = f"{self.api_base}/execute/{target}"
respx.post(url).mock(
return_value=httpx.Response(
status_code=status, json=json or {"result": {}}, headers=headers or {}
)
) # type: ignore
responses_lib.add(
responses_lib.POST,
url,
json=json or {"result": {}},
status=status,
headers=headers or {},
)
# ----- Memory -----
def mock_memory_get(self, result: Any, status: int = 200):
url = f"{self.api_base}/memory/get"
payload = result if isinstance(result, dict) else {"data": result}
respx.post(url).mock(
return_value=httpx.Response(status_code=status, json=payload)
) # type: ignore
responses_lib.add(responses_lib.POST, url, json=payload, status=status)
def mock_memory_delete(self, status: int = 200):
url = f"{self.api_base}/memory/delete"
respx.post(url).mock(
return_value=httpx.Response(status_code=status, json={"ok": True})
) # type: ignore
responses_lib.add(responses_lib.POST, url, json={"ok": True}, status=status)
def mock_memory_list(self, keys: List[str], status: int = 200):
url = f"{self.api_base}/memory/list"
respx.get(url).mock(
return_value=httpx.Response(
status_code=status, json=[{"key": k} for k in keys]
)
) # type: ignore
responses_lib.add(
responses_lib.GET, url, json=[{"key": k} for k in keys], status=status
)
@pytest.fixture
def http_mocks() -> HanzoAgentsHTTPMocks:
"""
Returns a helper for mocking Hanzo Agents server endpoints on both httpx and requests.
Note:
This works in concert with the autouse respx/responses wrappers already defined
at the top of this file.
Example:
def test_execute_headers_propagation(http_mocks, workflow_context):
ctx, headers = workflow_context # minimal by default
http_mocks.mock_execute("n.reasoner", json={"result": {"ok": True}})
# ... call HanzoAgentsClient.execute(...), ensure headers were passed ...
"""
return HanzoAgentsHTTPMocks()
# ---------------------------- 4) Sample Agent Fixture ----------------------------
@pytest.fixture
def sample_ai_config() -> AIConfig:
"""
Returns a pre-configured AIConfig safe for tests.
Example:
def test_ai_config_defaults(sample_ai_config):
assert sample_ai_config.model
"""
return AIConfig(
model="openai/gpt-4o-mini",
temperature=0.1,
max_tokens=64,
top_p=1.0,
stream=False,
timeout=10.0,
retry_attempts=0,
llm_params={},
)
@pytest.fixture
def mock_container_detection(monkeypatch):
"""
Forces container detection to a predictable value.
Example:
def test_callback_prefers_env(mock_container_detection):
mock_container_detection(is_container=False)
...
"""
from hanzo_agents import agent as agent_mod
def _apply(is_container: bool = False):
monkeypatch.setattr(
agent_mod, "_is_running_in_container", lambda: is_container, raising=True
)
return _apply
@pytest.fixture
def mock_ip_detection(monkeypatch):
"""
Mocks IP detection helpers used by Agent._resolve_callback_url.
Example:
def test_local_ip_fallback(mock_ip_detection, env_patch):
mock_ip_detection(container_ip=None, local_ip="10.0.0.2")
env_patch.unset("AGENT_CALLBACK_URL")
...
"""
from hanzo_agents import agent as agent_mod
def _apply(container_ip: Optional[str] = None, local_ip: Optional[str] = None):
monkeypatch.setattr(
agent_mod, "_detect_container_ip", lambda: container_ip, raising=True
)
monkeypatch.setattr(
agent_mod, "_detect_local_ip", lambda: local_ip, raising=True
)
return _apply
@pytest.fixture
def sample_agent(
env_patch, mock_container_detection, mock_ip_detection, sample_ai_config
) -> Agent:
"""
Constructs an Agent instance without serving (no network side-effects).
- dev_mode=True
- Avoids network calls during construction
- Pre-configured with safe defaults
Example:
def test_agent_init(sample_agent):
assert sample_agent.node_id == "test-node"
"""
# Ensure no env interference unless the test asks for it
env_patch.unset("AGENT_CALLBACK_URL")
env_patch.unset("RAILWAY_SERVICE_NAME")
env_patch.unset("RAILWAY_ENVIRONMENT")
# Force deterministic detection behavior
mock_container_detection(is_container=False)
mock_ip_detection(container_ip=None, local_ip="127.0.0.1")
agent = Agent(
node_id="test-node",
hanzo_agents_server="http://localhost:8080",
version="0.0.0",
ai_config=sample_ai_config,
memory_config=MemoryConfig(
auto_inject=[], memory_retention="session", cache_results=False
),
dev_mode=True,
callback_url="http://agent.local", # resolved to http://agent.local:8000 in __init__
)
return agent
# ---------------------------- 6) Fake Server Fixture (in-process FastAPI) ----------------------------
@pytest.fixture
def fake_server(monkeypatch, request):
"""
Spins up an in-process FastAPI mock server and routes HanzoAgentsClient calls to it WITHOUT real sockets.
This is suitable for contract tests while keeping network isolation.
Endpoints:
- POST /v1/nodes/register -> 201 Created
- POST /v1/execute/{target} -> 200 with {"result": {...}, "metadata": {...}}
- POST /v1/memory/get -> 200 with {"data": ...} or 404
- POST /v1/memory/delete -> 200
- GET /v1/memory/list?scope=... -> 200 with [{"key": ...}, ...]
How it works:
- Patches httpx.AsyncClient to use httpx.ASGITransport against the in-process FastAPI app.
- HanzoAgentsClient(async) calls are transparently routed; no sockets required.
- requests.* fallbacks are NOT routed here; rely on responses/respx for those.
Returns:
dict with:
- "base_url": str (e.g. "http://testserver")
- "app": FastAPI
- "memory": dict (in-memory store backing the memory endpoints)
"""
if FastAPI is None or httpx is None:
pytest.skip("fastapi/httpx are required for fake_server fixture")
app = FastAPI(title="Hanzo Agents Fake Server")
memory_store: Dict[str, Any] = {}
@app.post("/v1/nodes/register")
async def register_node(payload: Dict[str, Any]):
# Minimal 201 response for contract expectations
return JSONResponse(
status_code=201, content={"ok": True, "node": payload.get("id")}
)
@app.post("/v1/execute/{target}")
async def execute_target(target: str, payload: Dict[str, Any]):
# Echo input, fake metadata headers as body fields (clients parse json)
result = {
"result": {"echo": payload.get("input", {}), "target": target},
"metadata": {
"execution_id": "exec_" + uuid.uuid4().hex[:8],
"hanzo_agents_request_id": "req_" + uuid.uuid4().hex[:8],
"agent_node_id": target.split(".")[0] if "." in target else "node",
"duration_ms": 12,
"timestamp": "2024-01-01T00:00:00Z",
},
}
return JSONResponse(status_code=200, content=result)
@app.post("/v1/memory/get")
async def memory_get(payload: Dict[str, Any]):
key = payload.get("key")
if key in memory_store:
data = memory_store[key]
# Match SDK client expectations (it decodes "data")
return JSONResponse(
status_code=200,
content={
"data": json.dumps(data)
if not isinstance(data, (dict, list))
else data
},
)
return JSONResponse(status_code=404, content={"error": "not_found"})
@app.post("/v1/memory/delete")
async def memory_delete(payload: Dict[str, Any]):
key = payload.get("key")
memory_store.pop(key, None)
return JSONResponse(status_code=200, content={"ok": True})
@app.get("/v1/memory/list")
async def memory_list(scope: Optional[str] = None):
# Scope is ignored in this simple fake; return all keys
return JSONResponse(
status_code=200, content=[{"key": k} for k in sorted(memory_store.keys())]
)
# httpx ASGI transport plumbing
transport = httpx.ASGITransport(app=app)
RealAsyncClient = httpx.AsyncClient
def _patched_async_client(*args, **kwargs):
# Only patch if no explicit transport provided
if "transport" not in kwargs:
kwargs["transport"] = transport
if "base_url" not in kwargs:
kwargs["base_url"] = "http://testserver"
return RealAsyncClient(*args, **kwargs)
# Patch within this fixture scope
monkeypatch.setattr(httpx, "AsyncClient", _patched_async_client, raising=True)
resource = {
"base_url": "http://testserver",
"app": app,
"memory": memory_store,
}
yield resource
# cleanup: nothing persistent to stop; AsyncClient restored automatically by monkeypatch
# ---------------------------- Notes and Cross-Cutting Concerns ----------------------------
# - Agent.__init__ callback URL resolution is exercised via env_patch + mock_container_detection + mock_ip_detection
# - HanzoAgentsClient request/header propagation is covered by http_mocks and fake_server
# - MemoryClient serialization and HTTP fallback paths are supported by http_mocks and fake_server
# - AgentAI model limits caching and message trimming rely on llm_mock + sample_ai_config
# - AIConfig parameter merging and fallback logic can be tested via sample_ai_config overrides
# ---------------------------- Additional lightweight helpers ----------------------------
@pytest.fixture
def env_vars(monkeypatch: pytest.MonkeyPatch):
"""Simple helper to apply environment overrides inside a test. Pass keyword arguments with values or None to unset."""
def _apply(**values):
for key, value in values.items():
if value is None:
monkeypatch.delenv(key, raising=False)
else:
monkeypatch.setenv(key, str(value))
return _apply
@pytest.fixture
def dummy_headers():
"""Baseline execution headers consumed by memory/hanzo_agents client tests."""
return {
"X-Workflow-ID": "wf-test",
"X-Execution-ID": "exec-test",
"X-Hanzo Agents-Request-ID": "req-test",
}
@pytest.fixture
def responses(request):
"""Compat shim so tests can request a `responses` fixture for manual expectations."""
existing = getattr(request.node, "_responses_mock", None)
if existing is not None:
yield existing
return
with responses_lib.RequestsMock(assert_all_requests_are_fired=False) as rsps:
yield rsps