Skip to content

Commit 4d9e2c2

Browse files
NiteshDhanpalclaude
andcommitted
fix(compat): anchor version regex + add real-fetch and worker-wiring tests
- Anchor _VERSION_RE at both ends so a malformed tail (0.1.0rc1, 0.1.0foo, 0.1.0.1) is rejected to None ('unknown, proceed') instead of silently parsing as stable 0.1.0 and satisfying MIN_BACKEND_CONTRACT. - Test fetch_backend_version for real via httpx.MockTransport (success/URL, missing version, missing/null info, 404/503, non-JSON, connection error) plus end-to-end assert_backend_compatible through the real fetch. - Test the regex anchors explicitly (leading/trailing junk rejected; whitespace + leading v permitted). - Test AgentexWorker._register_agent wiring: guard runs before register_agent, incompatible backend blocks registration, no AGENTEX_BASE_URL skips both. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5bbb935 commit 4d9e2c2

5 files changed

Lines changed: 204 additions & 2 deletions

File tree

src/agentex/lib/core/compat/version_guard.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,15 @@
3434

3535
SKIP_ENV = "AGENTEX_SKIP_VERSION_CHECK"
3636

37-
# major.minor.patch, optional `-prerelease`; build metadata (after `+`) is ignored.
38-
_VERSION_RE = re.compile(r"^\s*v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?")
37+
# Full-string SemVer. Accepts: `1.2.3`, leading `v`, surrounding whitespace, `-prerelease`
38+
# (captured), `+build` (ignored). Anchored at both ends so a malformed tail (`0.1.0rc1`,
39+
# `0.1.0.1`) is rejected → None → "unknown, proceed", not silently coerced to stable `0.1.0`.
40+
_VERSION_RE = re.compile(
41+
r"^\s*v?(\d+)\.(\d+)\.(\d+)" # major.minor.patch
42+
r"(?:-([0-9A-Za-z.-]+))?" # optional -prerelease (captured)
43+
r"(?:\+[0-9A-Za-z.-]+)?" # optional +build metadata (ignored)
44+
r"\s*$"
45+
)
3946

4047

4148
class IncompatibleBackendError(RuntimeError):

tests/lib/core/temporal/__init__.py

Whitespace-only changes.

tests/lib/core/temporal/workers/__init__.py

Whitespace-only changes.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""AgentexWorker wires the backend version guard into worker startup.
2+
3+
A Temporal worker runs as its own process and never goes through the ACP server
4+
lifespan, so the guard must run inside `_register_agent` — before `register_agent`,
5+
and only when `AGENTEX_BASE_URL` is set.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from unittest.mock import Mock, AsyncMock
11+
12+
import pytest
13+
14+
from agentex.lib.core.temporal.workers import worker as worker_mod
15+
from agentex.lib.core.compat.version_guard import IncompatibleBackendError
16+
17+
18+
def _worker():
19+
# explicit health_check_port so __init__ doesn't read EnvironmentVariables
20+
return worker_mod.AgentexWorker(task_queue="test-queue", health_check_port=8080)
21+
22+
23+
def _patch_env(monkeypatch, base_url):
24+
env = Mock()
25+
env.AGENTEX_BASE_URL = base_url
26+
fake_cls = Mock()
27+
fake_cls.refresh.return_value = env
28+
monkeypatch.setattr(worker_mod, "EnvironmentVariables", fake_cls)
29+
return env
30+
31+
32+
async def test_guard_runs_before_register_agent(monkeypatch):
33+
env = _patch_env(monkeypatch, "http://backend")
34+
order: list[str] = []
35+
guard = AsyncMock(side_effect=lambda *a, **k: order.append("guard"))
36+
register = AsyncMock(side_effect=lambda *a, **k: order.append("register"))
37+
monkeypatch.setattr(worker_mod, "assert_backend_compatible", guard)
38+
monkeypatch.setattr(worker_mod, "register_agent", register)
39+
40+
await _worker()._register_agent()
41+
42+
guard.assert_awaited_once_with("http://backend")
43+
register.assert_awaited_once_with(env)
44+
assert order == ["guard", "register"] # guard must precede registration
45+
46+
47+
async def test_incompatible_backend_blocks_registration(monkeypatch):
48+
_patch_env(monkeypatch, "http://backend")
49+
guard = AsyncMock(side_effect=IncompatibleBackendError("backend too old"))
50+
register = AsyncMock()
51+
monkeypatch.setattr(worker_mod, "assert_backend_compatible", guard)
52+
monkeypatch.setattr(worker_mod, "register_agent", register)
53+
54+
with pytest.raises(IncompatibleBackendError):
55+
await _worker()._register_agent()
56+
57+
register.assert_not_awaited() # fail fast — never register against an unsupported backend
58+
59+
60+
async def test_no_base_url_skips_guard_and_registration(monkeypatch):
61+
_patch_env(monkeypatch, None)
62+
guard = AsyncMock()
63+
register = AsyncMock()
64+
monkeypatch.setattr(worker_mod, "assert_backend_compatible", guard)
65+
monkeypatch.setattr(worker_mod, "register_agent", register)
66+
67+
await _worker()._register_agent()
68+
69+
guard.assert_not_awaited()
70+
register.assert_not_awaited()

tests/test_version_guard.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import asyncio
66

7+
import httpx
78
import pytest
89

910
from agentex.lib.core.compat import version_guard as vg
@@ -13,14 +14,49 @@ def _run(coro):
1314
return asyncio.run(coro)
1415

1516

17+
def _patch_transport(monkeypatch, handler):
18+
"""Make version_guard's httpx.AsyncClient route through an in-memory MockTransport,
19+
so fetch_backend_version runs for real (request build, status check, JSON parse)
20+
without touching the network. `handler(request) -> httpx.Response` (or raises)."""
21+
22+
real_client = httpx.AsyncClient # capture before patching to avoid recursing into the factory
23+
24+
def factory(**kwargs):
25+
kwargs.pop("transport", None)
26+
return real_client(transport=httpx.MockTransport(handler), **kwargs)
27+
28+
monkeypatch.setattr(vg.httpx, "AsyncClient", factory)
29+
30+
1631
def test_parse_versions():
1732
assert vg._parse("0.2.1") == (0, 2, 1, None)
1833
assert vg._parse("v1.4.0") == (1, 4, 0, None)
1934
assert vg._parse("0.2.1-rc.1+build5") == (0, 2, 1, "rc.1") # build metadata ignored
35+
assert vg._parse("0.1.0+build5") == (0, 1, 0, None) # build metadata only, still stable
2036
assert vg._parse("garbage") is None
2137
assert vg._parse(None) is None
2238

2339

40+
def test_parse_rejects_malformed_tails():
41+
# Anchored regex: a junk tail after the triplet must NOT silently parse as stable 0.1.0;
42+
# it has to fall through to None (→ unknown / unparseable path), not satisfy the floor.
43+
for bad in ("0.1.0rc1", "0.1.0foo", "0.1.0.1", "0.1.0-", "1.2", "0.1.0-rc 1"):
44+
assert vg._parse(bad) is None, bad
45+
46+
47+
def test_parse_anchored_both_ends():
48+
# Leading anchor (^): anything before the triplet (other than whitespace / a `v`) is rejected.
49+
for bad in ("foo0.1.0", ">=0.1.0", "x0.1.0", "=0.1.0", "0 0.1.0"):
50+
assert vg._parse(bad) is None, bad
51+
# Trailing anchor ($): anything after the version (other than whitespace) is rejected.
52+
for bad in ("0.1.0 extra", "0.1.0;", "0.1.0/", "0.1.0+", "0.1.0 0.1.0"):
53+
assert vg._parse(bad) is None, bad
54+
# What the anchors DO permit: surrounding whitespace and an optional leading `v`.
55+
assert vg._parse(" 0.1.0 ") == (0, 1, 0, None)
56+
assert vg._parse("\tv1.2.3\n") == (1, 2, 3, None)
57+
assert vg._parse(" 0.2.0-rc.1 ") == (0, 2, 0, "rc.1")
58+
59+
2460
def test_prerelease_precedence():
2561
k = lambda v: vg._precedence_key(vg._parse(v)) # noqa: E731
2662
assert k("0.1.0-rc.1") < k("0.1.0") # prerelease precedes its stable release (SemVer §11)
@@ -81,3 +117,92 @@ async def fake(url, **kw):
81117
def test_no_base_url_is_noop():
82118
_run(vg.assert_backend_compatible(None))
83119
_run(vg.assert_backend_compatible(""))
120+
121+
122+
def test_truthy(monkeypatch):
123+
for val in ("1", "true", "True", "YES", "on"):
124+
monkeypatch.setenv("X_GUARD_FLAG", val)
125+
assert vg._truthy("X_GUARD_FLAG")
126+
for val in ("0", "false", "no", "off", ""):
127+
monkeypatch.setenv("X_GUARD_FLAG", val)
128+
assert not vg._truthy("X_GUARD_FLAG")
129+
monkeypatch.delenv("X_GUARD_FLAG", raising=False)
130+
assert not vg._truthy("X_GUARD_FLAG") # unset → falsy
131+
132+
133+
# --- fetch_backend_version: exercised for real through MockTransport (not mocked out) ---
134+
135+
136+
def test_fetch_success_and_url_construction(monkeypatch):
137+
seen = {}
138+
139+
def handler(request):
140+
seen["url"] = str(request.url)
141+
seen["method"] = request.method
142+
return httpx.Response(200, json={"openapi": "3.1.0", "info": {"version": "0.2.0"}})
143+
144+
_patch_transport(monkeypatch, handler)
145+
assert _run(vg.fetch_backend_version("http://backend/")) == "0.2.0"
146+
assert seen["url"] == "http://backend/openapi.json" # trailing slash trimmed, path appended
147+
assert seen["method"] == "GET"
148+
149+
150+
def test_fetch_missing_version_field(monkeypatch):
151+
_patch_transport(monkeypatch, lambda r: httpx.Response(200, json={"info": {}}))
152+
assert _run(vg.fetch_backend_version("http://backend")) is None
153+
154+
155+
def test_fetch_missing_info_object(monkeypatch):
156+
# `info` absent entirely, and `info: null` — both must coalesce to None, not crash.
157+
_patch_transport(monkeypatch, lambda r: httpx.Response(200, json={}))
158+
assert _run(vg.fetch_backend_version("http://backend")) is None
159+
_patch_transport(monkeypatch, lambda r: httpx.Response(200, json={"info": None}))
160+
assert _run(vg.fetch_backend_version("http://backend")) is None
161+
162+
163+
def test_fetch_http_error_status(monkeypatch):
164+
# raise_for_status() → caught → None (e.g. server has no /openapi.json)
165+
_patch_transport(monkeypatch, lambda r: httpx.Response(404, text="not found"))
166+
assert _run(vg.fetch_backend_version("http://backend")) is None
167+
_patch_transport(monkeypatch, lambda r: httpx.Response(503, text="unavailable"))
168+
assert _run(vg.fetch_backend_version("http://backend")) is None
169+
170+
171+
def test_fetch_non_json_body(monkeypatch):
172+
_patch_transport(monkeypatch, lambda r: httpx.Response(200, text="<html>nope</html>"))
173+
assert _run(vg.fetch_backend_version("http://backend")) is None
174+
175+
176+
def test_fetch_connection_error(monkeypatch):
177+
def handler(request):
178+
raise httpx.ConnectError("connection refused", request=request)
179+
180+
_patch_transport(monkeypatch, handler)
181+
assert _run(vg.fetch_backend_version("http://backend")) is None
182+
183+
184+
# --- assert_backend_compatible end-to-end: real fetch through MockTransport, not mocked out ---
185+
186+
187+
def test_assert_end_to_end_old_backend_raises(monkeypatch):
188+
monkeypatch.delenv(vg.SKIP_ENV, raising=False)
189+
_patch_transport(monkeypatch, lambda r: httpx.Response(200, json={"info": {"version": "0.0.9"}}))
190+
with pytest.raises(vg.IncompatibleBackendError):
191+
_run(vg.assert_backend_compatible("http://backend", min_version="0.1.0", sdk_version="0.13.0"))
192+
193+
194+
def test_assert_end_to_end_new_backend_passes(monkeypatch):
195+
monkeypatch.delenv(vg.SKIP_ENV, raising=False)
196+
_patch_transport(monkeypatch, lambda r: httpx.Response(200, json={"info": {"version": "0.2.0"}}))
197+
_run(vg.assert_backend_compatible("http://backend", min_version="0.1.0"))
198+
199+
200+
def test_assert_end_to_end_unreachable_backend_does_not_raise(monkeypatch):
201+
# real fetch returns None on connection failure → guard proceeds (no crash on transient blip)
202+
monkeypatch.delenv(vg.SKIP_ENV, raising=False)
203+
204+
def handler(request):
205+
raise httpx.ConnectError("refused", request=request)
206+
207+
_patch_transport(monkeypatch, handler)
208+
_run(vg.assert_backend_compatible("http://backend", min_version="9.9.9"))

0 commit comments

Comments
 (0)