Skip to content

Commit 7eabd02

Browse files
ryanmcmillanDelega Botclaude
authored
feat: accept DELEGA_AGENT_KEY env var + async test coverage → 0.2.1 (#7)
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: Delega Bot <hello@delega.dev> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b4df89f commit 7eabd02

7 files changed

Lines changed: 313 additions & 9 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ jobs:
2323
run: |
2424
python -m pip install --upgrade pip
2525
pip install -e ".[async]"
26-
pip install pytest
26+
pip install pytest pytest-asyncio
2727
2828
- name: Run tests
2929
run: pytest tests/ -v

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "delega"
7-
version = "0.2.0"
7+
version = "0.2.1"
88
description = "Official Python SDK for the Delega API"
99
readme = "README.md"
1010
license = "MIT"

src/delega/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
"""Package version metadata."""
22

3-
__version__ = "0.2.0"
3+
__version__ = "0.2.1"
44
USER_AGENT = f"delega-python/{__version__}"

src/delega/async_client.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -397,7 +397,9 @@ class AsyncDelega:
397397
398398
Args:
399399
api_key: API key for authentication. If not provided, reads from
400-
the ``DELEGA_API_KEY`` environment variable.
400+
the ``DELEGA_API_KEY`` environment variable, falling back to
401+
``DELEGA_AGENT_KEY`` for cross-client consistency with the
402+
``@delega-dev/mcp`` package.
401403
base_url: Base URL of the Delega API. Defaults to
402404
``https://api.delega.dev`` (normalized to ``/v1``). For
403405
self-hosted deployments, use ``http://localhost:18890`` or an
@@ -416,10 +418,15 @@ def __init__(
416418
base_url: str = _DEFAULT_BASE_URL,
417419
timeout: int = 30,
418420
) -> None:
419-
resolved_key = api_key or os.environ.get("DELEGA_API_KEY")
421+
resolved_key = (
422+
api_key
423+
or os.environ.get("DELEGA_API_KEY")
424+
or os.environ.get("DELEGA_AGENT_KEY")
425+
)
420426
if not resolved_key:
421427
raise DelegaError(
422-
"No API key provided. Pass api_key= or set the DELEGA_API_KEY environment variable."
428+
"No API key provided. Pass api_key= or set DELEGA_API_KEY "
429+
"(or DELEGA_AGENT_KEY) in the environment."
423430
)
424431
self._http = _AsyncHTTPClient(base_url=base_url, api_key=resolved_key, timeout=timeout)
425432
self.tasks = _AsyncTasksNamespace(self._http)

src/delega/client.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -453,7 +453,9 @@ class Delega:
453453
454454
Args:
455455
api_key: API key for authentication. If not provided, reads from
456-
the ``DELEGA_API_KEY`` environment variable.
456+
the ``DELEGA_API_KEY`` environment variable, falling back to
457+
``DELEGA_AGENT_KEY`` for cross-client consistency with the
458+
``@delega-dev/mcp`` package.
457459
base_url: Base URL of the Delega API. Defaults to
458460
``https://api.delega.dev`` (normalized to ``/v1``). For
459461
self-hosted deployments, use ``http://localhost:18890`` or an
@@ -471,10 +473,18 @@ def __init__(
471473
base_url: str = _DEFAULT_BASE_URL,
472474
timeout: int = 30,
473475
) -> None:
474-
resolved_key = api_key or os.environ.get("DELEGA_API_KEY")
476+
# Accept both env vars so agents configuring the MCP (primary:
477+
# DELEGA_AGENT_KEY) and this SDK (primary: DELEGA_API_KEY) in one
478+
# shell don't need to set both. DELEGA_API_KEY wins when both set.
479+
resolved_key = (
480+
api_key
481+
or os.environ.get("DELEGA_API_KEY")
482+
or os.environ.get("DELEGA_AGENT_KEY")
483+
)
475484
if not resolved_key:
476485
raise DelegaError(
477-
"No API key provided. Pass api_key= or set the DELEGA_API_KEY environment variable."
486+
"No API key provided. Pass api_key= or set DELEGA_API_KEY "
487+
"(or DELEGA_AGENT_KEY) in the environment."
478488
)
479489
self._http = HTTPClient(base_url=base_url, api_key=resolved_key, timeout=timeout)
480490
self.tasks = _TasksNamespace(self._http)

tests/test_async_client.py

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
"""Async client tests using httpx.MockTransport.
2+
3+
Mirrors the sync tests in test_client.py for the 1.2.0 coordination methods
4+
(assign, delegate, chain, update_context, find_duplicates) plus the 0.2.0
5+
usage() gate. Run with:
6+
7+
pytest tests/test_async_client.py
8+
9+
Requires httpx + pytest-asyncio (both in dev deps).
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import json
15+
from typing import Any
16+
17+
import pytest
18+
19+
import httpx
20+
21+
from delega import (
22+
AsyncDelega,
23+
DedupResult,
24+
DelegaError,
25+
DelegationChain,
26+
)
27+
28+
29+
def _json_handler(payload: Any, *, status: int = 200):
30+
"""Return an httpx request handler that replies with a fixed JSON payload."""
31+
32+
def handler(request: httpx.Request) -> httpx.Response:
33+
return httpx.Response(status, json=payload)
34+
35+
return handler
36+
37+
38+
def _recording_handler(payload: Any, recorded: list[httpx.Request]):
39+
"""Record every incoming request into ``recorded`` and reply with payload."""
40+
41+
def handler(request: httpx.Request) -> httpx.Response:
42+
recorded.append(request)
43+
return httpx.Response(200, json=payload)
44+
45+
return handler
46+
47+
48+
def _make_client(handler) -> AsyncDelega:
49+
"""Build an AsyncDelega wired to an httpx.MockTransport."""
50+
client = AsyncDelega(api_key="dlg_test", base_url="https://api.delega.dev")
51+
# Swap the transport for our mock — keeps normalize_base_url handling intact.
52+
transport = httpx.MockTransport(handler)
53+
client._http._client = httpx.AsyncClient(
54+
base_url=client._http._base_url,
55+
headers={"X-Agent-Key": "dlg_test", "User-Agent": "test"},
56+
transport=transport,
57+
)
58+
return client
59+
60+
61+
@pytest.mark.asyncio
62+
async def test_async_delegate_with_assignee():
63+
recorded: list[httpx.Request] = []
64+
client = _make_client(
65+
_recording_handler(
66+
{
67+
"id": "t_child",
68+
"content": "Child",
69+
"parent_task_id": "t1",
70+
"root_task_id": "t1",
71+
"delegation_depth": 1,
72+
"status": "open",
73+
"assigned_to_agent_id": "a2",
74+
},
75+
recorded,
76+
)
77+
)
78+
async with client:
79+
task = await client.tasks.delegate(
80+
"t1", "Child", assigned_to_agent_id="a2", priority=2
81+
)
82+
assert task.parent_task_id == "t1"
83+
assert task.delegation_depth == 1
84+
assert task.assigned_to_agent_id == "a2"
85+
assert recorded[0].url.path.endswith("/v1/tasks/t1/delegate")
86+
body = json.loads(recorded[0].content.decode())
87+
assert body["assigned_to_agent_id"] == "a2"
88+
assert body["priority"] == 2
89+
90+
91+
@pytest.mark.asyncio
92+
async def test_async_assign_task():
93+
recorded: list[httpx.Request] = []
94+
client = _make_client(
95+
_recording_handler(
96+
{"id": "t1", "content": "x", "assigned_to_agent_id": "a5"}, recorded
97+
)
98+
)
99+
async with client:
100+
task = await client.tasks.assign("t1", "a5")
101+
assert task.assigned_to_agent_id == "a5"
102+
assert recorded[0].method == "PUT"
103+
body = json.loads(recorded[0].content.decode())
104+
assert body == {"assigned_to_agent_id": "a5"}
105+
106+
107+
@pytest.mark.asyncio
108+
async def test_async_assign_unassign():
109+
recorded: list[httpx.Request] = []
110+
client = _make_client(
111+
_recording_handler({"id": "t1", "content": "x"}, recorded)
112+
)
113+
async with client:
114+
await client.tasks.assign("t1", None)
115+
body = json.loads(recorded[0].content.decode())
116+
assert body["assigned_to_agent_id"] is None
117+
118+
119+
@pytest.mark.asyncio
120+
async def test_async_chain_hosted_shape():
121+
client = _make_client(
122+
_json_handler(
123+
{
124+
"root_id": "abc",
125+
"chain": [
126+
{"id": "abc", "content": "root", "delegation_depth": 0}
127+
],
128+
"depth": 0,
129+
"completed_count": 0,
130+
"total_count": 1,
131+
}
132+
)
133+
)
134+
async with client:
135+
chain = await client.tasks.chain("abc")
136+
assert isinstance(chain, DelegationChain)
137+
assert chain.root_id == "abc"
138+
assert len(chain.chain) == 1
139+
140+
141+
@pytest.mark.asyncio
142+
async def test_async_chain_self_hosted_shape():
143+
"""Self-hosted returns {root: Task} without root_id — client normalizes."""
144+
client = _make_client(
145+
_json_handler(
146+
{
147+
"root": {"id": 42, "content": "root"},
148+
"chain": [
149+
{"id": 42, "content": "root", "delegation_depth": 0}
150+
],
151+
"depth": 0,
152+
"completed_count": 0,
153+
"total_count": 1,
154+
}
155+
)
156+
)
157+
async with client:
158+
chain = await client.tasks.chain("42")
159+
assert chain.root_id == "42"
160+
161+
162+
@pytest.mark.asyncio
163+
async def test_async_update_context_hosted_bare_dict():
164+
recorded: list[httpx.Request] = []
165+
client = _make_client(
166+
_recording_handler({"step": "done", "count": 2}, recorded)
167+
)
168+
async with client:
169+
merged = await client.tasks.update_context("t1", {"count": 2})
170+
assert merged == {"step": "done", "count": 2}
171+
assert recorded[0].method == "PATCH"
172+
assert recorded[0].url.path.endswith("/v1/tasks/t1/context")
173+
174+
175+
@pytest.mark.asyncio
176+
async def test_async_update_context_self_hosted_full_task():
177+
client = _make_client(
178+
_json_handler(
179+
{
180+
"id": 42,
181+
"content": "x",
182+
"completed": False,
183+
"context": {"step": "done", "count": 2},
184+
}
185+
)
186+
)
187+
async with client:
188+
merged = await client.tasks.update_context("42", {"count": 2})
189+
assert merged == {"step": "done", "count": 2}
190+
191+
192+
@pytest.mark.asyncio
193+
async def test_async_find_duplicates():
194+
recorded: list[httpx.Request] = []
195+
client = _make_client(
196+
_recording_handler(
197+
{
198+
"has_duplicates": True,
199+
"matches": [
200+
{
201+
"task_id": "abc",
202+
"content": "research pricing",
203+
"score": 0.85,
204+
}
205+
],
206+
},
207+
recorded,
208+
)
209+
)
210+
async with client:
211+
result = await client.tasks.find_duplicates(
212+
"Research pricing", threshold=0.7
213+
)
214+
assert isinstance(result, DedupResult)
215+
assert result.has_duplicates
216+
assert len(result.matches) == 1
217+
assert result.matches[0].score == 0.85
218+
body = json.loads(recorded[0].content.decode())
219+
assert body == {"content": "Research pricing", "threshold": 0.7}
220+
221+
222+
@pytest.mark.asyncio
223+
async def test_async_usage_hosted():
224+
recorded: list[httpx.Request] = []
225+
client = _make_client(
226+
_recording_handler(
227+
{
228+
"plan": "free",
229+
"task_count_month": 42,
230+
"task_limit": 1000,
231+
"rate_limit_rpm": 60,
232+
},
233+
recorded,
234+
)
235+
)
236+
async with client:
237+
result = await client.usage()
238+
assert result["plan"] == "free"
239+
assert recorded[0].url.path.endswith("/v1/usage")
240+
241+
242+
@pytest.mark.asyncio
243+
async def test_async_usage_self_hosted_raises_before_fetch():
244+
"""Self-hosted should raise DelegaError without touching the transport."""
245+
recorded: list[httpx.Request] = []
246+
247+
def handler(request: httpx.Request) -> httpx.Response:
248+
recorded.append(request)
249+
return httpx.Response(200, json={})
250+
251+
client = AsyncDelega(
252+
api_key="dlg_test", base_url="http://127.0.0.1:18890"
253+
)
254+
client._http._client = httpx.AsyncClient(
255+
base_url=client._http._base_url,
256+
headers={"X-Agent-Key": "dlg_test"},
257+
transport=httpx.MockTransport(handler),
258+
)
259+
async with client:
260+
with pytest.raises(DelegaError) as ctx:
261+
await client.usage()
262+
assert "only available on the hosted" in str(ctx.value)
263+
assert not recorded, "transport should not have been called"
264+
265+
266+
@pytest.mark.asyncio
267+
async def test_async_accepts_DELEGA_AGENT_KEY_fallback(monkeypatch):
268+
"""Agent-side env-var consistency with @delega-dev/mcp."""
269+
monkeypatch.delenv("DELEGA_API_KEY", raising=False)
270+
monkeypatch.setenv("DELEGA_AGENT_KEY", "dlg_from_agent_env")
271+
client = AsyncDelega()
272+
assert client._http._api_key == "dlg_from_agent_env"

tests/test_client.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,21 @@ def test_api_key_from_param(self) -> None:
6969
client = Delega(api_key="dlg_direct")
7070
self.assertEqual(client._http._api_key, "dlg_direct")
7171

72+
def test_api_key_falls_back_to_DELEGA_AGENT_KEY(self) -> None:
73+
"""Cross-client consistency with @delega-dev/mcp (which primaries DELEGA_AGENT_KEY)."""
74+
with patch.dict(os.environ, {}, clear=True):
75+
os.environ["DELEGA_AGENT_KEY"] = "dlg_from_agent_env"
76+
client = Delega()
77+
self.assertEqual(client._http._api_key, "dlg_from_agent_env")
78+
79+
def test_DELEGA_API_KEY_wins_over_DELEGA_AGENT_KEY(self) -> None:
80+
"""When both env vars are set, DELEGA_API_KEY is the primary."""
81+
with patch.dict(os.environ, {}, clear=True):
82+
os.environ["DELEGA_API_KEY"] = "dlg_primary"
83+
os.environ["DELEGA_AGENT_KEY"] = "dlg_fallback"
84+
client = Delega()
85+
self.assertEqual(client._http._api_key, "dlg_primary")
86+
7287
def test_remote_base_url_defaults_to_v1_namespace(self) -> None:
7388
client = Delega(api_key="dlg_test", base_url="https://custom.host")
7489
self.assertEqual(client._http._base_url, "https://custom.host/v1")

0 commit comments

Comments
 (0)