From dacda399174c169d4e769e947b0d450a0dd114b4 Mon Sep 17 00:00:00 2001 From: himanshu Date: Wed, 13 May 2026 09:50:20 +0530 Subject: [PATCH 1/3] Release helloagentai 0.1.1 with handler tests --- .github/workflows/ci.yml | 6 +- .github/workflows/release.yml | 10 +- README.md | 2 +- helloagent/client.py | 13 +- pyproject.toml | 16 +- tests/test_agent_handler.py | 138 ++++++++++++ tests/test_client.py | 392 ++++++++++++++++++++++++++++++++++ tests/test_helpers.py | 355 ++++++++++++++++++++++++++++++ 8 files changed, 921 insertions(+), 11 deletions(-) create mode 100644 tests/test_agent_handler.py create mode 100644 tests/test_client.py create mode 100644 tests/test_helpers.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 979ccff..ce7579c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,11 @@ jobs: - name: Install run: | python -m pip install --upgrade pip build - pip install -e . + pip install -e ".[test]" + - name: Lint + run: ruff check helloagent tests + - name: Test + run: pytest - name: Import smoke run: | python -c "from helloagent import Agent, UserClient, IncomingMessage, AuthFailedError, Tool, ToolRegistry; print('imports ok')" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b202f43..acaf6c7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,8 +14,14 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" - - name: Install build tooling - run: python -m pip install --upgrade build + - name: Install tooling + run: | + python -m pip install --upgrade pip build + pip install -e ".[test]" + - name: Lint + run: ruff check helloagent tests + - name: Test + run: pytest - name: Build sdist and wheel run: python -m build - uses: actions/upload-artifact@v4 diff --git a/README.md b/README.md index be9631e..43141e0 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ await client.send("alice/jarvis", "what's on my calendar today?") ## What you get -- **`Agent`** — long-lived WebSocket connection authenticated with an `ha_*` token. Auto-reconnects with exponential backoff. Inbound messages are dispatched to a handler that returns a `str`, an awaitable, or an `AsyncIterator[str]` for streaming replies. +- **`Agent`** — long-lived WebSocket connection authenticated with an `ha_*` token. Auto-reconnects with exponential backoff. Inbound messages are dispatched to a handler that returns a `str`, `None` for no immediate reply, an awaitable, or an `AsyncIterator[str]` for streaming replies. - **`UserClient`** — same transport, `ROLE_USER`. For user-facing surfaces. - **`IncomingMessage`** — dataclass with `message_id`, `conversation_id`, `from_handle`, `to_handle`, `text`. - **`AuthFailedError`** — raised when the relay rejects auth (`auth_response.ok=false`). Treat as terminal: re-pair, don't retry. diff --git a/helloagent/client.py b/helloagent/client.py index a1ce3c7..2f7497b 100644 --- a/helloagent/client.py +++ b/helloagent/client.py @@ -37,7 +37,8 @@ class IncomingMessage: text: str -Handler = Callable[[IncomingMessage], Union[str, Awaitable[str], AsyncIterator[str]]] +HandlerResult = Union[str, None, Awaitable[Optional[str]], AsyncIterator[str]] +Handler = Callable[[IncomingMessage], HandlerResult] class AuthFailedError(Exception): @@ -76,7 +77,8 @@ async def _connect_once(self): ) await self.ws.send(auth.SerializeToString()) raw = await self.ws.recv() - env = pb.Envelope(); env.ParseFromString(raw) + env = pb.Envelope() + env.ParseFromString(raw) if not env.HasField("auth_response") or not env.auth_response.ok: raise AuthFailedError(str(env)) if env.auth_response.handle: @@ -88,7 +90,8 @@ async def _send(self, env: pb.Envelope): async def _recv(self) -> pb.Envelope: raw = await self.ws.recv() - env = pb.Envelope(); env.ParseFromString(raw) + env = pb.Envelope() + env.ParseFromString(raw) return env @@ -189,7 +192,9 @@ async def _handle(self, env: pb.Envelope): await self._send_chunk(incoming, env.message_id, "", True) else: text = await result if inspect.isawaitable(result) else result - await self._send_chunk(incoming, env.message_id, text or "", True) + if text is None: + return + await self._send_chunk(incoming, env.message_id, text, True) async def _send_chunk(self, incoming: IncomingMessage, ref_id: str, body: str, final: bool): sess = self._session_for(incoming.from_handle) diff --git a/pyproject.toml b/pyproject.toml index 25710b7..cb5e3c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,17 +4,16 @@ build-backend = "setuptools.build_meta" [project] name = "helloagentai" -version = "0.1.0" +version = "0.1.1" description = "HelloAgent Python SDK — relay client + channel-link helpers. Install as `helloagentai`, import as `helloagent`." readme = "README.md" -license = {text = "MIT"} +license = "MIT" requires-python = ">=3.10" authors = [{name = "HelloAgent"}] keywords = ["helloagent", "agent", "websocket", "relay", "messaging", "sdk", "ai-agent"] classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -27,6 +26,14 @@ dependencies = [ "cryptography>=42.0", ] +[project.optional-dependencies] +test = [ + "pytest>=8", + "pytest-asyncio>=0.24", + "pytest-cov>=5", + "ruff>=0.8", +] + [project.urls] Homepage = "https://github.com/helloagentai/helloagent-sdk-python" Documentation = "https://github.com/helloagentai/helloagent-sdk-python#readme" @@ -38,3 +45,6 @@ include = ["helloagent*"] [tool.setuptools.package-data] helloagent = ["v1/*.py"] + +[tool.pytest.ini_options] +asyncio_mode = "strict" diff --git a/tests/test_agent_handler.py b/tests/test_agent_handler.py new file mode 100644 index 0000000..3a8a73c --- /dev/null +++ b/tests/test_agent_handler.py @@ -0,0 +1,138 @@ +import pytest + +from helloagent import Agent +from helloagent.v1 import protocol_pb2 as pb + + +def _incoming_env() -> pb.Envelope: + return pb.Envelope( + message_id="in_1", + ts_unix_ms=123, + send_message=pb.SendMessage( + conversation_id="conv_1", + from_handle="alice", + to_handle="alice/jarvis", + text="hello", + ), + ) + + +def _capture_agent() -> tuple[Agent, list[pb.Envelope]]: + agent = Agent("ha_test") + agent.handle = "alice/jarvis" + sent = [] + + async def fake_send(env): + sent.append(env) + + agent._send = fake_send + return agent, sent + + +@pytest.mark.asyncio +async def test_none_handler_result_acks_without_reply_chunk(): + agent, sent = _capture_agent() + + @agent.on_message + async def handler(msg): + return None + + await agent._handle(_incoming_env()) + + assert [env.WhichOneof("payload") for env in sent] == ["ack"] + + +@pytest.mark.asyncio +async def test_string_handler_result_sends_final_reply_chunk(): + agent, sent = _capture_agent() + + @agent.on_message + async def handler(msg): + return "hi" + + await agent._handle(_incoming_env()) + + assert [env.WhichOneof("payload") for env in sent] == ["ack", "stream_chunk"] + chunk = sent[1].stream_chunk + assert chunk.body == "hi" + assert chunk.is_final is True + + +@pytest.mark.asyncio +async def test_sync_none_handler_result_acks_without_reply_chunk(): + agent, sent = _capture_agent() + + @agent.on_message + def handler(msg): + return None + + await agent._handle(_incoming_env()) + + assert [env.WhichOneof("payload") for env in sent] == ["ack"] + + +@pytest.mark.asyncio +async def test_missing_handler_only_acks(): + agent, sent = _capture_agent() + + await agent._handle(_incoming_env()) + + assert [env.WhichOneof("payload") for env in sent] == ["ack"] + + +@pytest.mark.asyncio +async def test_async_generator_handler_streams_chunks_then_final_empty_chunk(): + agent, sent = _capture_agent() + + @agent.on_message + async def handler(msg): + yield "one" + yield "two" + + await agent._handle(_incoming_env()) + + assert [env.WhichOneof("payload") for env in sent] == [ + "ack", + "stream_chunk", + "stream_chunk", + "stream_chunk", + ] + chunks = [env.stream_chunk for env in sent[1:]] + assert [(chunk.body, chunk.is_final) for chunk in chunks] == [ + ("one", False), + ("two", False), + ("", True), + ] + + +@pytest.mark.asyncio +async def test_agent_send_builds_send_message_envelope(): + class FakeWS: + def __init__(self): + self.payloads = [] + + async def send(self, payload): + self.payloads.append(payload) + + agent = Agent("ha_test") + agent.handle = "alice/jarvis" + agent.ws = FakeWS() + + message_id = await agent.send("bob", "hello", conversation_id="conv_1") + + assert message_id + env = pb.Envelope() + env.ParseFromString(agent.ws.payloads[0]) + assert env.message_id == message_id + assert env.send_message.conversation_id == "conv_1" + assert env.send_message.from_handle == "alice/jarvis" + assert env.send_message.to_handle == "bob" + assert env.send_message.text == "hello" + + +@pytest.mark.asyncio +async def test_agent_send_requires_connection(): + agent = Agent("ha_test") + + with pytest.raises(RuntimeError, match="agent not connected"): + await agent.send("bob", "hello") diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..4be128d --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,392 @@ +import asyncio +import json +import urllib.error + +import pytest + +import helloagent.client as client +from helloagent import Agent, AuthFailedError, UserClient, claim_handle, login_user, register_user +from helloagent.v1 import protocol_pb2 as pb + + +class FakeWS: + def __init__(self, incoming=None): + self.incoming = list(incoming or []) + self.sent = [] + + async def send(self, payload): + self.sent.append(payload) + + async def recv(self): + return self.incoming.pop(0) + + +class FakeSession: + def __init__(self): + self.encrypted = [] + self.decrypted = [] + + def encrypt(self, plaintext: bytes) -> bytes: + self.encrypted.append(plaintext) + return b"cipher:" + plaintext + + def decrypt(self, wire: bytes) -> bytes: + self.decrypted.append(wire) + return b"decrypted text" + + +def _bytes(env: pb.Envelope) -> bytes: + return env.SerializeToString() + + +def _auth_response(*, ok: bool = True, handle: str = "alice/jarvis") -> bytes: + return _bytes( + pb.Envelope( + message_id="auth_1", + ts_unix_ms=1, + auth_response=pb.AuthResponse(ok=ok, handle=handle), + ) + ) + + +def _send_message(*, encrypted: bool = False) -> pb.Envelope: + return pb.Envelope( + message_id="in_1", + ts_unix_ms=1, + send_message=pb.SendMessage( + conversation_id="conv_1", + from_handle="alice", + to_handle="alice/jarvis", + text="" if encrypted else "hello", + encrypted_body=b"wire" if encrypted else b"", + is_encrypted=encrypted, + ), + ) + + +@pytest.mark.asyncio +async def test_connect_once_sends_auth_request_and_updates_server_handle(monkeypatch): + ws = FakeWS([_auth_response(handle="server/handle")]) + + async def fake_connect(url, max_size): + assert url == "ws://relay.test/v1/ws" + assert max_size == 2**20 + return ws + + monkeypatch.setattr(client.websockets, "connect", fake_connect) + agent = Agent("ha_test", relay_url="ws://relay.test/v1/ws") + + await agent._connect_once() + + assert agent.ws is ws + assert agent.handle == "server/handle" + env = pb.Envelope() + env.ParseFromString(ws.sent[0]) + assert env.auth_request.token == "ha_test" + assert env.auth_request.role == pb.ROLE_AGENT + + +@pytest.mark.asyncio +async def test_connect_once_raises_terminal_auth_failure(monkeypatch): + ws = FakeWS([_auth_response(ok=False, handle="")]) + + async def fake_connect(*_args, **_kwargs): + return ws + + monkeypatch.setattr(client.websockets, "connect", fake_connect) + agent = Agent("ha_test") + + with pytest.raises(AuthFailedError): + await agent._connect_once() + + +@pytest.mark.asyncio +async def test_agent_loop_dispatches_inbound_send_messages(monkeypatch): + agent = Agent("ha_test") + incoming = [_send_message()] + created = [] + + async def fake_recv(): + if incoming: + return incoming.pop(0) + raise RuntimeError("stop") + + def fake_create_task(coro): + created.append(coro) + coro.close() + return object() + + agent._recv = fake_recv + monkeypatch.setattr(asyncio, "create_task", fake_create_task) + + with pytest.raises(RuntimeError, match="stop"): + await agent._loop() + + assert len(created) == 1 + + +@pytest.mark.asyncio +async def test_base_recv_parses_next_websocket_envelope(): + inbound = _send_message() + agent = Agent("ha_test") + agent.ws = FakeWS([inbound.SerializeToString()]) + + received = await agent._recv() + + assert received.message_id == inbound.message_id + assert received.send_message.text == inbound.send_message.text + + +@pytest.mark.asyncio +async def test_agent_run_reconnects_with_backoff(monkeypatch, caplog): + agent = Agent("ha_test") + calls = [] + + async def fake_connect_once(): + calls.append("connect") + + async def fake_loop(): + calls.append("loop") + raise RuntimeError("socket dropped") + + async def fake_sleep(delay): + calls.append(("sleep", delay)) + raise StopAsyncIteration + + agent._connect_once = fake_connect_once + agent._loop = fake_loop + monkeypatch.setattr(client.asyncio, "sleep", fake_sleep) + + with pytest.raises(StopAsyncIteration): + await agent.run() + + assert calls == ["connect", "loop", ("sleep", 1)] + assert "reconnecting in 1s" in caplog.text + + +def test_agent_connect_runs_async_run(monkeypatch): + agent = Agent("ha_test") + calls = [] + + def fake_run(coro): + calls.append(coro) + coro.close() + + monkeypatch.setattr(client.asyncio, "run", fake_run) + + agent.connect() + + assert len(calls) == 1 + + +@pytest.mark.asyncio +async def test_encrypted_message_without_session_is_dropped(caplog): + agent = Agent("ha_test") + sent = [] + + async def fake_send(env): + sent.append(env) + + agent._send = fake_send + + await agent._handle(_send_message(encrypted=True)) + + assert sent == [] + assert "no session" in caplog.text + + +@pytest.mark.asyncio +async def test_encrypted_inbound_text_and_reply_chunks_use_session(): + agent = Agent("ha_test") + agent.handle = "alice/jarvis" + sent = [] + session = FakeSession() + agent.set_peer_session("alice", session) + + async def fake_send(env): + sent.append(env) + + agent._send = fake_send + + @agent.on_message + def handler(msg): + assert msg.text == "decrypted text" + return "secret reply" + + await agent._handle(_send_message(encrypted=True)) + + assert session.decrypted == [b"wire"] + assert session.encrypted == [b"secret reply"] + assert [env.WhichOneof("payload") for env in sent] == ["ack", "stream_chunk"] + chunk = sent[1].stream_chunk + assert chunk.is_encrypted is True + assert chunk.encrypted_body == b"cipher:secret reply" + assert chunk.body == "" + + +@pytest.mark.asyncio +async def test_user_client_send_builds_plain_message_with_default_conversation(): + user = UserClient(handle="alice") + user.ws = FakeWS() + + message_id = await user.send("alice/jarvis", "hello") + + env = pb.Envelope() + env.ParseFromString(user.ws.sent[0]) + assert env.message_id == message_id + assert env.send_message.conversation_id == "alice:alice/jarvis" + assert env.send_message.from_handle == "alice" + assert env.send_message.to_handle == "alice/jarvis" + assert env.send_message.text == "hello" + + +@pytest.mark.asyncio +async def test_user_client_send_encrypts_when_peer_session_exists(): + user = UserClient(handle="alice") + user.ws = FakeWS() + session = FakeSession() + user.set_peer_session("alice/jarvis", session) + + await user.send("alice/jarvis", "hello", conversation_id="conv_1") + + env = pb.Envelope() + env.ParseFromString(user.ws.sent[0]) + assert session.encrypted == [b"hello"] + assert env.send_message.conversation_id == "conv_1" + assert env.send_message.is_encrypted is True + assert env.send_message.encrypted_body == b"cipher:hello" + assert env.send_message.text == "" + + +@pytest.mark.asyncio +async def test_user_client_connect_and_recv_delegate_to_base_methods(): + user = UserClient(handle="alice") + connected = [] + inbound = _send_message() + + async def fake_connect_once(): + connected.append(True) + + async def fake_recv(): + return inbound + + user._connect_once = fake_connect_once + user._recv = fake_recv + + await user.connect() + + assert connected == [True] + assert await user.recv() is inbound + + +def test_user_client_requires_handle_or_token(): + with pytest.raises(ValueError, match="handle or token required"): + UserClient() + + +def test_agent_token_sets_empty_handle_until_server_authenticates(): + assert Agent("ha_test").handle == "" + assert Agent("legacy-handle").handle == "legacy-handle" + + +def test_agent_tool_decorator_registers_tool(): + agent = Agent("ha_test") + + @agent.tool(name="echo", description="Echo text") + def echo(text: str) -> str: + return text + + assert "echo" in agent.tools + assert agent.tools["echo"].fn("hi") == "hi" + assert agent.tools.schemas()[0]["name"] == "echo" + + +def test_removed_auth_helpers_raise_clear_error(): + with pytest.raises(NotImplementedError, match="Supabase Auth"): + register_user("alice", "pw") + + with pytest.raises(NotImplementedError, match="Supabase Auth"): + login_user("alice", "pw") + + +def test_claim_handle_posts_profile_and_decodes_json(monkeypatch): + seen = {} + + class Response: + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self): + return json.dumps({"handle": "alice"}).encode() + + def fake_urlopen(req): + seen["url"] = req.full_url + seen["headers"] = dict(req.header_items()) + seen["body"] = req.data + return Response() + + monkeypatch.setattr(client.urllib.request, "urlopen", fake_urlopen) + + assert claim_handle("jwt", "alice", api="https://api.test") == {"handle": "alice"} + assert seen["url"] == "https://api.test/v1/profile" + assert seen["headers"]["Authorization"] == "Bearer jwt" + assert json.loads(seen["body"]) == {"handle": "alice"} + + +def test_claim_handle_surfaces_http_error_body(monkeypatch): + def fake_urlopen(_req): + raise urllib.error.HTTPError( + url="https://api.test/v1/profile", + code=409, + msg="conflict", + hdrs=None, + fp=None, + ) + + monkeypatch.setattr(client.urllib.request, "urlopen", fake_urlopen) + + with pytest.raises(RuntimeError, match="409:"): + claim_handle("jwt", "alice", api="https://api.test") + + +def test_http_json_posts_json_and_surfaces_http_errors(monkeypatch): + seen = {} + + class Response: + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self): + return json.dumps({"ok": True}).encode() + + def fake_urlopen(req): + seen["url"] = req.full_url + seen["headers"] = dict(req.header_items()) + seen["body"] = req.data + return Response() + + monkeypatch.setattr(client.urllib.request, "urlopen", fake_urlopen) + + assert client._http_json("https://api.test/endpoint", {"x": 1}) == {"ok": True} + assert seen["url"] == "https://api.test/endpoint" + assert json.loads(seen["body"]) == {"x": 1} + + def fake_error(_req): + raise urllib.error.HTTPError( + url="https://api.test/endpoint", + code=418, + msg="teapot", + hdrs=None, + fp=None, + ) + + monkeypatch.setattr(client.urllib.request, "urlopen", fake_error) + + with pytest.raises(RuntimeError, match="418:"): + client._http_json("https://api.test/endpoint", {"x": 1}) diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 0000000..04f0c14 --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,355 @@ +import asyncio +import io +import json +import sys +import urllib.error +import urllib.parse + +import pytest +from cryptography.hazmat.primitives import serialization + +from helloagent import channels, discovery, keystore, tokens +from helloagent.crypto import KeyPair, Session, load_public +from helloagent.tools import ToolRegistry + + +class Response: + def __init__(self, payload=None, *, status=200, raw=None): + self.payload = payload + self.status = status + self.raw = raw + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self): + if self.raw is not None: + return self.raw + return json.dumps(self.payload).encode() + + +def test_token_payload_roundtrip_and_generation(monkeypatch): + entropy = bytes(range(32)) + payload = tokens.TokenPayload(entropy=entropy, issued_at=123, scope_flags=7) + + encoded = payload.encode() + parsed = tokens.parse(encoded) + + assert tokens.is_ha_token(encoded) is True + assert parsed == payload + + monkeypatch.setattr(tokens.os, "urandom", lambda size: b"x" * size) + monkeypatch.setattr(tokens.time, "time", lambda: 456.9) + + generated = tokens.parse(tokens.generate(scope_flags=2)) + assert generated.entropy == b"x" * 32 + assert generated.issued_at == 456 + assert generated.scope_flags == 2 + + +def test_token_validation_errors(): + assert tokens._b62_encode(b"\x00") == "0" + + with pytest.raises(ValueError, match="missing 'ha_' prefix"): + tokens.parse("not-a-token") + + with pytest.raises(ValueError, match="entropy must be 32 bytes"): + tokens.TokenPayload(entropy=b"short", issued_at=1, scope_flags=0).encode() + + with pytest.raises(ValueError, match="decoded payload is 39 bytes"): + tokens.parse("ha_" + tokens._b62_encode(b"x" * 39)) + + +def test_crypto_keypair_roundtrip_and_session_encryption(): + alice = KeyPair.generate() + bob = KeyPair.generate() + + restored = KeyPair.from_private_bytes(alice.private_bytes()) + assert restored.public_bytes() == alice.public_bytes() + assert load_public(bob.public_bytes()).public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) == bob.public_bytes() + + alice_session = Session.from_keypair(alice, bob.public) + bob_session = Session.from_keypair(bob, alice.public) + wire = alice_session.encrypt(b"hello", associated=b"conv_1") + + assert wire != b"hello" + assert bob_session.decrypt(wire, associated=b"conv_1") == b"hello" + + with pytest.raises(ValueError, match="ciphertext too short"): + bob_session.decrypt(b"short") + + +@pytest.mark.asyncio +async def test_tool_registry_infers_schema_and_invokes_sync_and_async_tools(): + registry = ToolRegistry() + + def add(count: int, label: str = "x") -> str: + """Add a label.""" + return label * count + + async def double(value: int) -> int: + return value * 2 + + def method_like(self, value: bool): + return value + + add_tool = registry.register(add) + double_tool = registry.register(double, parameters={"type": "object"}) + method_tool = registry.register(method_like, name="method") + + assert len(registry) == 3 + assert "add" in registry + assert [tool.name for tool in registry] == ["add", "double", "method"] + assert add_tool.schema()["description"] == "Add a label." + assert add_tool.schema()["parameters"] == { + "type": "object", + "properties": { + "count": {"type": "integer"}, + "label": {"type": "string"}, + }, + "required": ["count"], + } + assert await add_tool.invoke(count=3, label="a") == "aaa" + assert await double_tool.invoke(value=4) == 8 + assert registry.schemas()[1]["parameters"] == {"type": "object"} + assert method_tool.schema()["parameters"]["properties"] == {"value": {"type": "boolean"}} + + +def test_keystore_uses_keyring_module(monkeypatch): + calls = [] + + class FakeKeyring: + @staticmethod + def set_password(service, account, token): + calls.append(("set", service, account, token)) + + @staticmethod + def get_password(service, account): + calls.append(("get", service, account)) + return "ha_saved" + + @staticmethod + def delete_password(service, account): + calls.append(("delete", service, account)) + + monkeypatch.setitem(sys.modules, "keyring", FakeKeyring) + + keystore.save_token("alice", "ha_saved") + assert keystore.load_token("alice") == "ha_saved" + keystore.delete_token("alice") + + assert calls == [ + ("set", "helloagent", "alice", "ha_saved"), + ("get", "helloagent", "alice"), + ("delete", "helloagent", "alice"), + ] + + +def test_keystore_missing_keyring_has_clear_error(monkeypatch): + monkeypatch.setitem(sys.modules, "keyring", None) + + with pytest.raises(RuntimeError, match="keyring support not installed"): + keystore.save_token("alice", "ha_saved") + + +def test_keystore_delete_swallows_backend_errors(monkeypatch): + class BrokenKeyring: + @staticmethod + def delete_password(_service, _account): + raise RuntimeError("backend down") + + monkeypatch.setitem(sys.modules, "keyring", BrokenKeyring) + + keystore.delete_token("alice") + + +def test_channels_request_success_and_wrappers(monkeypatch): + seen = [] + + def fake_urlopen(req): + seen.append( + { + "url": req.full_url, + "method": req.get_method(), + "headers": dict(req.header_items()), + "body": req.data, + } + ) + if req.full_url.endswith("/channels"): + return Response([{"provider": "openclaw"}]) + return Response({"ok": True, "token": "ha_test"}, status=201) + + monkeypatch.setattr(channels.urllib.request, "urlopen", fake_urlopen) + + assert channels.link("openclaw", "jwt", agent_name="jarvis", api="https://api.test") == { + "ok": True, + "token": "ha_test", + } + assert channels.list_channels("jwt", api="https://api.test") == [{"provider": "openclaw"}] + assert channels.unlink("openclaw", "jwt", api="https://api.test") is None + + assert seen[0]["url"] == "https://api.test/v1/channels/openclaw/link" + assert seen[0]["method"] == "POST" + assert seen[0]["headers"]["Authorization"] == "Bearer jwt" + assert json.loads(seen[0]["body"]) == {"agent_name": "jarvis"} + assert seen[1]["method"] == "GET" + assert seen[1]["body"] is None + assert seen[2]["method"] == "DELETE" + + +def test_channels_request_handles_empty_json_and_http_errors(monkeypatch): + monkeypatch.setattr( + channels.urllib.request, + "urlopen", + lambda _req: Response(raw=b"", status=204), + ) + assert channels.list_channels("jwt", api="https://api.test") == [] + + def json_error(_req): + raise urllib.error.HTTPError( + url="https://api.test/v1/channels", + code=400, + msg="bad", + hdrs=None, + fp=io.BytesIO(b'{"code":"bad_request","message":"Nope"}'), + ) + + monkeypatch.setattr(channels.urllib.request, "urlopen", json_error) + with pytest.raises(RuntimeError, match="400 bad_request: Nope"): + channels.list_channels("jwt", api="https://api.test") + + def text_error(_req): + raise urllib.error.HTTPError( + url="https://api.test/v1/channels", + code=500, + msg="bad", + hdrs=None, + fp=io.BytesIO(b"plain failure"), + ) + + monkeypatch.setattr(channels.urllib.request, "urlopen", text_error) + with pytest.raises(RuntimeError, match="500 http: plain failure"): + channels.list_channels("jwt", api="https://api.test") + + +def test_oauth_helpers_build_expected_requests(monkeypatch): + seen = [] + + def fake_urlopen(req): + seen.append( + { + "url": req.full_url, + "method": req.get_method(), + "headers": dict(req.header_items()), + "body": req.data, + } + ) + return Response({"ok": True}) + + monkeypatch.setattr(channels.urllib.request, "urlopen", fake_urlopen) + + assert channels.oauth_authorize( + "jwt", + "client", + "http://localhost/cb", + state="s1", + code_challenge="challenge", + api="https://api.test", + ) == {"ok": True} + assert channels.oauth_token( + "client", + "secret", + "code", + "http://localhost/cb", + code_verifier="verifier", + api="https://api.test", + ) == {"ok": True} + assert channels.oauth_device_authorize("client", api="https://api.test") == {"ok": True} + assert channels.oauth_device_approve( + "jwt", + "client", + "ABCD", + api="https://api.test", + ) == {"ok": True} + assert channels.oauth_device_token("client", "device", api="https://api.test") == {"ok": True} + + assert seen[0]["url"] == "https://api.test/oauth/authorize" + assert json.loads(seen[0]["body"])["code_challenge"] == "challenge" + + token_form = urllib.parse.parse_qs(seen[1]["body"].decode()) + assert token_form["client_secret"] == ["secret"] + assert token_form["code_verifier"] == ["verifier"] + token_headers = {key.lower(): value for key, value in seen[1]["headers"].items()} + assert token_headers["content-type"] == "application/x-www-form-urlencoded" + + device_form = urllib.parse.parse_qs(seen[4]["body"].decode()) + assert device_form["grant_type"] == ["urn:ietf:params:oauth:grant-type:device_code"] + + +def test_oauth_token_helpers_surface_http_errors(monkeypatch): + def json_error(_req): + raise urllib.error.HTTPError( + url="https://api.test/oauth/token", + code=401, + msg="bad", + hdrs=None, + fp=io.BytesIO(b'{"code":"invalid","message":"Bad code"}'), + ) + + monkeypatch.setattr(channels.urllib.request, "urlopen", json_error) + + with pytest.raises(RuntimeError, match="401 invalid: Bad code"): + channels.oauth_token("client", None, "code", "http://localhost/cb") + + with pytest.raises(RuntimeError, match="401 invalid: Bad code"): + channels.oauth_device_token("client", "device") + + def text_error(_req): + raise urllib.error.HTTPError( + url="https://api.test/oauth/token", + code=502, + msg="bad", + hdrs=None, + fp=io.BytesIO(b"proxy exploded"), + ) + + monkeypatch.setattr(channels.urllib.request, "urlopen", text_error) + + with pytest.raises(RuntimeError, match="502 http: proxy exploded"): + channels.oauth_token("client", None, "code", "http://localhost/cb") + + with pytest.raises(RuntimeError, match="502 http: proxy exploded"): + channels.oauth_device_token("client", "device") + + +def test_discovery_posts_manifest_preview_request(monkeypatch): + seen = {} + + def fake_req(method, url, token, body=None): + seen.update({"method": method, "url": url, "token": token, "body": body}) + return 200, {"suggested_handle": "alice/jarvis"} + + monkeypatch.setattr(discovery, "_req", fake_req) + + assert discovery.discover( + "https://agent.test", + "jwt", + auth_credential="secret", + api="https://api.test", + ) == {"suggested_handle": "alice/jarvis"} + assert seen == { + "method": "POST", + "url": "https://api.test/v1/discover", + "token": "jwt", + "body": {"url": "https://agent.test", "auth_credential": "secret"}, + } + + +def test_asyncio_import_is_real_for_async_tool_tests(): + assert asyncio.iscoroutinefunction(asyncio.sleep) From e66dfe7a943b1f711c35bf455a7c57bdecbf3c5c Mon Sep 17 00:00:00 2001 From: himanshu Date: Wed, 13 May 2026 09:57:07 +0530 Subject: [PATCH 2/3] Use current GitHub action majors --- .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce7579c..58a8e5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,8 +13,8 @@ jobs: matrix: python-version: ["3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Install diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index acaf6c7..ad6a78b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,8 +10,8 @@ jobs: name: Build distributions runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 with: python-version: "3.12" - name: Install tooling From e9416e1779b240d6d74c8b56dc3ea3a447f27aee Mon Sep 17 00:00:00 2001 From: himanshu Date: Wed, 13 May 2026 10:07:08 +0530 Subject: [PATCH 3/3] Test against Python 3.14 --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- pyproject.toml | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58a8e5e..c468ce4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v6 - uses: actions/setup-python@v6 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ad6a78b..d808d41 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,7 @@ jobs: - uses: actions/checkout@v6 - uses: actions/setup-python@v6 with: - python-version: "3.12" + python-version: "3.14" - name: Install tooling run: | python -m pip install --upgrade pip build diff --git a/pyproject.toml b/pyproject.toml index cb5e3c9..6fca41c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,8 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [