From 90e8a1f5612fcdc2b87486cce76c018082929ab5 Mon Sep 17 00:00:00 2001 From: Delega Bot Date: Tue, 14 Apr 2026 13:38:39 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20accept=20DELEGA=5FAGENT=5FKEY=20env=20v?= =?UTF-8?q?ar=20+=20async=20test=20coverage=20=E2=86=92=200.2.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small additions shipped together as a patch. 1. Env var tolerance (T2.4) - Accept DELEGA_AGENT_KEY as fallback for DELEGA_API_KEY so agents configuring @delega-dev/mcp (primary: DELEGA_AGENT_KEY) and this SDK (primary: DELEGA_API_KEY) in one shell don't need to set both. - DELEGA_API_KEY still wins when both are set. - Applied to both Delega and AsyncDelega. - 2 new sync tests, 1 new async test cover the env fallback. 2. Async test coverage (T3.1) - New tests/test_async_client.py with 11 tests using httpx.MockTransport to mirror the sync coverage added in 0.2.0 — delegate/assign/chain/ update_context/find_duplicates each covered with both hosted and self-hosted response shapes where the shape differs. Also covers the hosted-only usage() gate with a "mock transport never called" assertion. - .github/workflows/ci.yml gains pytest-asyncio so CI actually runs the new file across the Python 3.9-3.13 matrix. Full test suite: 76 passed (65 sync + 11 async). Sibling patches: @delega-dev/mcp@1.2.1 and @delega-dev/cli@1.2.1 both shipped. The three packages are now consistent on env var handling. T2.4 + T3.1 from followups-after-1.2.0-night.md Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 2 +- pyproject.toml | 2 +- src/delega/_version.py | 2 +- src/delega/async_client.py | 13 +- src/delega/client.py | 16 ++- tests/test_async_client.py | 272 +++++++++++++++++++++++++++++++++++++ tests/test_client.py | 15 ++ 7 files changed, 313 insertions(+), 9 deletions(-) create mode 100644 tests/test_async_client.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03abd0a..97d82c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: run: | python -m pip install --upgrade pip pip install -e ".[async]" - pip install pytest + pip install pytest pytest-asyncio - name: Run tests run: pytest tests/ -v diff --git a/pyproject.toml b/pyproject.toml index 07a19f8..5e806b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "delega" -version = "0.2.0" +version = "0.2.1" description = "Official Python SDK for the Delega API" readme = "README.md" license = "MIT" diff --git a/src/delega/_version.py b/src/delega/_version.py index b835363..ae21986 100644 --- a/src/delega/_version.py +++ b/src/delega/_version.py @@ -1,4 +1,4 @@ """Package version metadata.""" -__version__ = "0.2.0" +__version__ = "0.2.1" USER_AGENT = f"delega-python/{__version__}" diff --git a/src/delega/async_client.py b/src/delega/async_client.py index e07035b..333e633 100644 --- a/src/delega/async_client.py +++ b/src/delega/async_client.py @@ -397,7 +397,9 @@ class AsyncDelega: Args: api_key: API key for authentication. If not provided, reads from - the ``DELEGA_API_KEY`` environment variable. + the ``DELEGA_API_KEY`` environment variable, falling back to + ``DELEGA_AGENT_KEY`` for cross-client consistency with the + ``@delega-dev/mcp`` package. base_url: Base URL of the Delega API. Defaults to ``https://api.delega.dev`` (normalized to ``/v1``). For self-hosted deployments, use ``http://localhost:18890`` or an @@ -416,10 +418,15 @@ def __init__( base_url: str = _DEFAULT_BASE_URL, timeout: int = 30, ) -> None: - resolved_key = api_key or os.environ.get("DELEGA_API_KEY") + resolved_key = ( + api_key + or os.environ.get("DELEGA_API_KEY") + or os.environ.get("DELEGA_AGENT_KEY") + ) if not resolved_key: raise DelegaError( - "No API key provided. Pass api_key= or set the DELEGA_API_KEY environment variable." + "No API key provided. Pass api_key= or set DELEGA_API_KEY " + "(or DELEGA_AGENT_KEY) in the environment." ) self._http = _AsyncHTTPClient(base_url=base_url, api_key=resolved_key, timeout=timeout) self.tasks = _AsyncTasksNamespace(self._http) diff --git a/src/delega/client.py b/src/delega/client.py index 92420dd..bde2c90 100644 --- a/src/delega/client.py +++ b/src/delega/client.py @@ -453,7 +453,9 @@ class Delega: Args: api_key: API key for authentication. If not provided, reads from - the ``DELEGA_API_KEY`` environment variable. + the ``DELEGA_API_KEY`` environment variable, falling back to + ``DELEGA_AGENT_KEY`` for cross-client consistency with the + ``@delega-dev/mcp`` package. base_url: Base URL of the Delega API. Defaults to ``https://api.delega.dev`` (normalized to ``/v1``). For self-hosted deployments, use ``http://localhost:18890`` or an @@ -471,10 +473,18 @@ def __init__( base_url: str = _DEFAULT_BASE_URL, timeout: int = 30, ) -> None: - resolved_key = api_key or os.environ.get("DELEGA_API_KEY") + # Accept both env vars so agents configuring the MCP (primary: + # DELEGA_AGENT_KEY) and this SDK (primary: DELEGA_API_KEY) in one + # shell don't need to set both. DELEGA_API_KEY wins when both set. + resolved_key = ( + api_key + or os.environ.get("DELEGA_API_KEY") + or os.environ.get("DELEGA_AGENT_KEY") + ) if not resolved_key: raise DelegaError( - "No API key provided. Pass api_key= or set the DELEGA_API_KEY environment variable." + "No API key provided. Pass api_key= or set DELEGA_API_KEY " + "(or DELEGA_AGENT_KEY) in the environment." ) self._http = HTTPClient(base_url=base_url, api_key=resolved_key, timeout=timeout) self.tasks = _TasksNamespace(self._http) diff --git a/tests/test_async_client.py b/tests/test_async_client.py new file mode 100644 index 0000000..f36703a --- /dev/null +++ b/tests/test_async_client.py @@ -0,0 +1,272 @@ +"""Async client tests using httpx.MockTransport. + +Mirrors the sync tests in test_client.py for the 1.2.0 coordination methods +(assign, delegate, chain, update_context, find_duplicates) plus the 0.2.0 +usage() gate. Run with: + + pytest tests/test_async_client.py + +Requires httpx + pytest-asyncio (both in dev deps). +""" + +from __future__ import annotations + +import json +from typing import Any + +import pytest + +import httpx + +from delega import ( + AsyncDelega, + DedupResult, + DelegaError, + DelegationChain, +) + + +def _json_handler(payload: Any, *, status: int = 200): + """Return an httpx request handler that replies with a fixed JSON payload.""" + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(status, json=payload) + + return handler + + +def _recording_handler(payload: Any, recorded: list[httpx.Request]): + """Record every incoming request into ``recorded`` and reply with payload.""" + + def handler(request: httpx.Request) -> httpx.Response: + recorded.append(request) + return httpx.Response(200, json=payload) + + return handler + + +def _make_client(handler) -> AsyncDelega: + """Build an AsyncDelega wired to an httpx.MockTransport.""" + client = AsyncDelega(api_key="dlg_test", base_url="https://api.delega.dev") + # Swap the transport for our mock — keeps normalize_base_url handling intact. + transport = httpx.MockTransport(handler) + client._http._client = httpx.AsyncClient( + base_url=client._http._base_url, + headers={"X-Agent-Key": "dlg_test", "User-Agent": "test"}, + transport=transport, + ) + return client + + +@pytest.mark.asyncio +async def test_async_delegate_with_assignee(): + recorded: list[httpx.Request] = [] + client = _make_client( + _recording_handler( + { + "id": "t_child", + "content": "Child", + "parent_task_id": "t1", + "root_task_id": "t1", + "delegation_depth": 1, + "status": "open", + "assigned_to_agent_id": "a2", + }, + recorded, + ) + ) + async with client: + task = await client.tasks.delegate( + "t1", "Child", assigned_to_agent_id="a2", priority=2 + ) + assert task.parent_task_id == "t1" + assert task.delegation_depth == 1 + assert task.assigned_to_agent_id == "a2" + assert recorded[0].url.path.endswith("/v1/tasks/t1/delegate") + body = json.loads(recorded[0].content.decode()) + assert body["assigned_to_agent_id"] == "a2" + assert body["priority"] == 2 + + +@pytest.mark.asyncio +async def test_async_assign_task(): + recorded: list[httpx.Request] = [] + client = _make_client( + _recording_handler( + {"id": "t1", "content": "x", "assigned_to_agent_id": "a5"}, recorded + ) + ) + async with client: + task = await client.tasks.assign("t1", "a5") + assert task.assigned_to_agent_id == "a5" + assert recorded[0].method == "PUT" + body = json.loads(recorded[0].content.decode()) + assert body == {"assigned_to_agent_id": "a5"} + + +@pytest.mark.asyncio +async def test_async_assign_unassign(): + recorded: list[httpx.Request] = [] + client = _make_client( + _recording_handler({"id": "t1", "content": "x"}, recorded) + ) + async with client: + await client.tasks.assign("t1", None) + body = json.loads(recorded[0].content.decode()) + assert body["assigned_to_agent_id"] is None + + +@pytest.mark.asyncio +async def test_async_chain_hosted_shape(): + client = _make_client( + _json_handler( + { + "root_id": "abc", + "chain": [ + {"id": "abc", "content": "root", "delegation_depth": 0} + ], + "depth": 0, + "completed_count": 0, + "total_count": 1, + } + ) + ) + async with client: + chain = await client.tasks.chain("abc") + assert isinstance(chain, DelegationChain) + assert chain.root_id == "abc" + assert len(chain.chain) == 1 + + +@pytest.mark.asyncio +async def test_async_chain_self_hosted_shape(): + """Self-hosted returns {root: Task} without root_id — client normalizes.""" + client = _make_client( + _json_handler( + { + "root": {"id": 42, "content": "root"}, + "chain": [ + {"id": 42, "content": "root", "delegation_depth": 0} + ], + "depth": 0, + "completed_count": 0, + "total_count": 1, + } + ) + ) + async with client: + chain = await client.tasks.chain("42") + assert chain.root_id == "42" + + +@pytest.mark.asyncio +async def test_async_update_context_hosted_bare_dict(): + recorded: list[httpx.Request] = [] + client = _make_client( + _recording_handler({"step": "done", "count": 2}, recorded) + ) + async with client: + merged = await client.tasks.update_context("t1", {"count": 2}) + assert merged == {"step": "done", "count": 2} + assert recorded[0].method == "PATCH" + assert recorded[0].url.path.endswith("/v1/tasks/t1/context") + + +@pytest.mark.asyncio +async def test_async_update_context_self_hosted_full_task(): + client = _make_client( + _json_handler( + { + "id": 42, + "content": "x", + "completed": False, + "context": {"step": "done", "count": 2}, + } + ) + ) + async with client: + merged = await client.tasks.update_context("42", {"count": 2}) + assert merged == {"step": "done", "count": 2} + + +@pytest.mark.asyncio +async def test_async_find_duplicates(): + recorded: list[httpx.Request] = [] + client = _make_client( + _recording_handler( + { + "has_duplicates": True, + "matches": [ + { + "task_id": "abc", + "content": "research pricing", + "score": 0.85, + } + ], + }, + recorded, + ) + ) + async with client: + result = await client.tasks.find_duplicates( + "Research pricing", threshold=0.7 + ) + assert isinstance(result, DedupResult) + assert result.has_duplicates + assert len(result.matches) == 1 + assert result.matches[0].score == 0.85 + body = json.loads(recorded[0].content.decode()) + assert body == {"content": "Research pricing", "threshold": 0.7} + + +@pytest.mark.asyncio +async def test_async_usage_hosted(): + recorded: list[httpx.Request] = [] + client = _make_client( + _recording_handler( + { + "plan": "free", + "task_count_month": 42, + "task_limit": 1000, + "rate_limit_rpm": 60, + }, + recorded, + ) + ) + async with client: + result = await client.usage() + assert result["plan"] == "free" + assert recorded[0].url.path.endswith("/v1/usage") + + +@pytest.mark.asyncio +async def test_async_usage_self_hosted_raises_before_fetch(): + """Self-hosted should raise DelegaError without touching the transport.""" + recorded: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + recorded.append(request) + return httpx.Response(200, json={}) + + client = AsyncDelega( + api_key="dlg_test", base_url="http://127.0.0.1:18890" + ) + client._http._client = httpx.AsyncClient( + base_url=client._http._base_url, + headers={"X-Agent-Key": "dlg_test"}, + transport=httpx.MockTransport(handler), + ) + async with client: + with pytest.raises(DelegaError) as ctx: + await client.usage() + assert "only available on the hosted" in str(ctx.value) + assert not recorded, "transport should not have been called" + + +@pytest.mark.asyncio +async def test_async_accepts_DELEGA_AGENT_KEY_fallback(monkeypatch): + """Agent-side env-var consistency with @delega-dev/mcp.""" + monkeypatch.delenv("DELEGA_API_KEY", raising=False) + monkeypatch.setenv("DELEGA_AGENT_KEY", "dlg_from_agent_env") + client = AsyncDelega() + assert client._http._api_key == "dlg_from_agent_env" diff --git a/tests/test_client.py b/tests/test_client.py index a2ec1e5..f8d0803 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -69,6 +69,21 @@ def test_api_key_from_param(self) -> None: client = Delega(api_key="dlg_direct") self.assertEqual(client._http._api_key, "dlg_direct") + def test_api_key_falls_back_to_DELEGA_AGENT_KEY(self) -> None: + """Cross-client consistency with @delega-dev/mcp (which primaries DELEGA_AGENT_KEY).""" + with patch.dict(os.environ, {}, clear=True): + os.environ["DELEGA_AGENT_KEY"] = "dlg_from_agent_env" + client = Delega() + self.assertEqual(client._http._api_key, "dlg_from_agent_env") + + def test_DELEGA_API_KEY_wins_over_DELEGA_AGENT_KEY(self) -> None: + """When both env vars are set, DELEGA_API_KEY is the primary.""" + with patch.dict(os.environ, {}, clear=True): + os.environ["DELEGA_API_KEY"] = "dlg_primary" + os.environ["DELEGA_AGENT_KEY"] = "dlg_fallback" + client = Delega() + self.assertEqual(client._http._api_key, "dlg_primary") + def test_remote_base_url_defaults_to_v1_namespace(self) -> None: client = Delega(api_key="dlg_test", base_url="https://custom.host") self.assertEqual(client._http._base_url, "https://custom.host/v1")