Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/delega/_version.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Package version metadata."""

__version__ = "0.2.0"
__version__ = "0.2.1"
USER_AGENT = f"delega-python/{__version__}"
13 changes: 10 additions & 3 deletions src/delega/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
16 changes: 13 additions & 3 deletions src/delega/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
272 changes: 272 additions & 0 deletions tests/test_async_client.py
Original file line number Diff line number Diff line change
@@ -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"
15 changes: 15 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down