molecule-core/workspace/tests/test_a2a_client.py
Hongming Wang 645c1862c4 feat(a2a-client): surface 410 Gone as 'removed' error so callers can re-onboard (#2429)
Follow-up A to PR #2449 — that PR taught the platform to return 410
Gone for status='removed' workspaces; this PR teaches get_workspace_info
to consume that signal.

Before: every non-200 collapsed into {"error": "not found"}, which
made the 2026-04-30 incident impossible to diagnose — the operator
KNEW the workspace_id existed (they'd just registered it), but the
runtime kept reporting "not found" for a deleted-but-not-purged row.

After: 410 produces a distinct {"error": "removed", "id", "removed_at",
"hint"} dict so callers (heartbeat-loop, channel bridge, dashboard
tools) can surface "your workspace was deleted, re-onboard" instead
of "not found". Falls back to a default hint if the platform body
isn't parseable so the actionable signal doesn't depend on body
shape parity.

Two new tests:
  - TestGetWorkspaceInfo.test_410_returns_removed_with_hint
  - TestGetWorkspaceInfo.test_410_with_unparseable_body_falls_back_to_default_hint

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 22:08:08 -07:00

889 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Comprehensive tests for a2a_client.py — 100% statement coverage.
Tests every async function: discover_peer, send_a2a_message, get_peers,
get_workspace_info. Each test covers exactly one execution path so failures
are easy to diagnose.
"""
import sys
import os
import importlib
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_mock_client(*, get_resp=None, post_resp=None, get_exc=None, post_exc=None):
"""Build a reusable AsyncClient context-manager mock."""
mock_client = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
if get_exc is not None:
mock_client.get = AsyncMock(side_effect=get_exc)
elif get_resp is not None:
mock_client.get = AsyncMock(return_value=get_resp)
if post_exc is not None:
mock_client.post = AsyncMock(side_effect=post_exc)
elif post_resp is not None:
mock_client.post = AsyncMock(return_value=post_resp)
return mock_client
def _make_response(status_code, json_data):
resp = MagicMock()
resp.status_code = status_code
resp.json = MagicMock(return_value=json_data)
return resp
# Canonical UUID used wherever a test needs a peer_id. send_a2a_message and
# discover_peer reject non-UUID strings at the trust boundary (see
# a2a_client._validate_peer_id), so test inputs must be valid UUIDs.
_TEST_PEER_ID = "11111111-1111-1111-1111-111111111111"
# ---------------------------------------------------------------------------
# Module-level constants (just ensure they exist and have sensible types)
# ---------------------------------------------------------------------------
def test_constants_exist():
import a2a_client
assert isinstance(a2a_client.PLATFORM_URL, str)
assert isinstance(a2a_client.WORKSPACE_ID, str)
assert isinstance(a2a_client._A2A_ERROR_PREFIX, str)
assert isinstance(a2a_client._peer_names, dict)
# ---------------------------------------------------------------------------
# discover_peer
# ---------------------------------------------------------------------------
class TestDiscoverPeer:
async def test_success_returns_json_on_200(self):
"""200 response → returns the JSON body."""
import a2a_client
peer_data = {"id": _TEST_PEER_ID, "url": "http://ws-abc.svc", "name": "Alpha"}
resp = _make_response(200, peer_data)
mock_client = _make_mock_client(get_resp=resp)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
result = await a2a_client.discover_peer(_TEST_PEER_ID)
assert result == peer_data
async def test_non_200_returns_none(self):
"""Non-200 response → returns None."""
import a2a_client
resp = _make_response(404, {"detail": "not found"})
mock_client = _make_mock_client(get_resp=resp)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
result = await a2a_client.discover_peer(_TEST_PEER_ID)
assert result is None
async def test_403_returns_none(self):
"""403 forbidden → returns None (any non-200 code)."""
import a2a_client
resp = _make_response(403, {"detail": "forbidden"})
mock_client = _make_mock_client(get_resp=resp)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
result = await a2a_client.discover_peer(_TEST_PEER_ID)
assert result is None
async def test_exception_returns_none(self):
"""Network exception → returns None (exception swallowed)."""
import a2a_client
mock_client = _make_mock_client(get_exc=ConnectionError("host unreachable"))
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
result = await a2a_client.discover_peer(_TEST_PEER_ID)
assert result is None
async def test_invalid_peer_id_returns_none_without_http(self):
"""Malformed peer_id is rejected at the trust boundary — no HTTP call.
Path-traversal-shaped input ("../admin"), free-form labels
("ws-abc"), and empty strings all return None and don't reach
the platform. Closes the URL-interpolation class of bug.
"""
import a2a_client
mock_client = _make_mock_client(get_resp=_make_response(200, {}))
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
for bad in ("", "ws-abc", "../admin", "not-a-uuid", "8dad3e29"):
assert await a2a_client.discover_peer(bad) is None
# No GET should have been issued for any of those.
mock_client.get.assert_not_called()
async def test_request_uses_correct_url_and_header(self):
"""GET is called with the right URL and X-Workspace-ID header."""
import a2a_client
resp = _make_response(200, {"url": "http://target"})
mock_client = _make_mock_client(get_resp=resp)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
await a2a_client.discover_peer(_TEST_PEER_ID)
mock_client.get.assert_called_once()
positional_url = mock_client.get.call_args.args[0]
assert _TEST_PEER_ID in positional_url
# X-Workspace-ID must be present; bearer token also merged in when available
headers_sent = mock_client.get.call_args.kwargs.get("headers", {})
assert headers_sent.get("X-Workspace-ID") == a2a_client.WORKSPACE_ID
# ---------------------------------------------------------------------------
# send_a2a_message
# ---------------------------------------------------------------------------
class TestSendA2AMessage:
async def test_result_with_text_part_returns_text(self):
"""'result' key with text parts → returns the text."""
import a2a_client
resp = _make_response(200, {
"result": {"parts": [{"kind": "text", "text": "Hello!"}]}
})
mock_client = _make_mock_client(post_resp=resp)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
result = await a2a_client.send_a2a_message(_TEST_PEER_ID, "ping")
assert result == "Hello!"
async def test_result_with_empty_parts_returns_no_response(self):
"""'result' key with empty parts list → returns '(no response)'."""
import a2a_client
resp = _make_response(200, {"result": {"parts": []}})
mock_client = _make_mock_client(post_resp=resp)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
result = await a2a_client.send_a2a_message(_TEST_PEER_ID, "ping")
assert result == "(no response)"
async def test_result_text_starts_with_agent_error_gets_prefix(self):
"""Text starting with 'Agent error:' gets the _A2A_ERROR_PREFIX prepended."""
import a2a_client
resp = _make_response(200, {
"result": {"parts": [{"kind": "text", "text": "Agent error: something bad"}]}
})
mock_client = _make_mock_client(post_resp=resp)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
result = await a2a_client.send_a2a_message(_TEST_PEER_ID, "task")
assert result.startswith(a2a_client._A2A_ERROR_PREFIX)
assert "Agent error: something bad" in result
async def test_error_key_returns_error_prefix_and_message(self):
"""'error' key in response → returns _A2A_ERROR_PREFIX + error message."""
import a2a_client
resp = _make_response(200, {
"error": {"code": -32603, "message": "Internal error occurred"}
})
mock_client = _make_mock_client(post_resp=resp)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
result = await a2a_client.send_a2a_message(_TEST_PEER_ID, "task")
assert result.startswith(a2a_client._A2A_ERROR_PREFIX)
assert "Internal error occurred" in result
async def test_error_key_missing_message_returns_unknown(self):
"""'error' key without 'message' → falls back to 'unknown'."""
import a2a_client
resp = _make_response(200, {"error": {"code": -32600}})
mock_client = _make_mock_client(post_resp=resp)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
result = await a2a_client.send_a2a_message(_TEST_PEER_ID, "task")
assert result.startswith(a2a_client._A2A_ERROR_PREFIX)
# The error includes the JSON-RPC code so the operator can look it
# up; "no message" surfaces the missing-message condition explicitly
# instead of the previous opaque "unknown".
assert "code=-32600" in result
assert "no message" in result.lower()
# Target URL is included so chained delegations are traceable.
# Target URL now constructed internally — assert it contains the peer_id
# and the proxy path, not the old hand-passed URL.
assert _TEST_PEER_ID in result
assert "/workspaces/" in result and "/a2a" in result
async def test_jsonrpc_error_with_code_zero_includes_code_in_detail(self):
"""JSON-RPC error code=0 is technically not valid in the spec,
but a malformed peer can still send it — make sure the code is
preserved in the detail rather than collapsing into the
no-code path. Locks in the `code is not None` semantics over
the truthy-check shortcut."""
import a2a_client
resp = _make_response(200, {"error": {"code": 0, "message": "weird"}})
mock_client = _make_mock_client(post_resp=resp)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
result = await a2a_client.send_a2a_message(_TEST_PEER_ID, "task")
assert result.startswith(a2a_client._A2A_ERROR_PREFIX)
assert "code=0" in result
assert "weird" in result
async def test_neither_result_nor_error_returns_a2a_error_with_payload(self):
"""Response with neither 'result' nor 'error' → A2A_ERROR + payload context."""
import a2a_client
payload = {"jsonrpc": "2.0", "id": "abc123"}
resp = _make_response(200, payload)
mock_client = _make_mock_client(post_resp=resp)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
result = await a2a_client.send_a2a_message(_TEST_PEER_ID, "task")
# Pre-fix this returned bare str(payload) which the canvas
# rendered as a confusing "looks like a successful response"
# block. Now it's tagged so downstream UI / delegate_task
# routes it through the error path.
assert result.startswith(a2a_client._A2A_ERROR_PREFIX)
assert "unexpected response shape" in result
assert "abc123" in result # snippet of payload included for context
# Target URL now constructed internally — assert it contains the peer_id
# and the proxy path, not the old hand-passed URL.
assert _TEST_PEER_ID in result
assert "/workspaces/" in result and "/a2a" in result
async def test_exception_returns_error_prefix_and_message(self):
"""Network exception → returns _A2A_ERROR_PREFIX + exception text."""
import a2a_client
mock_client = _make_mock_client(post_exc=ConnectionError("connection refused"))
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
result = await a2a_client.send_a2a_message(_TEST_PEER_ID, "task")
assert result.startswith(a2a_client._A2A_ERROR_PREFIX)
assert "connection refused" in result
# Exception class name is prepended when the message doesn't
# already include it — gives the operator a typed handle to
# search for in container logs.
assert "ConnectionError" in result
# Target URL now constructed internally — assert it contains the peer_id
# and the proxy path, not the old hand-passed URL.
assert _TEST_PEER_ID in result
assert "/workspaces/" in result and "/a2a" in result
async def test_empty_stringifying_exception_falls_back_to_class_name(self):
"""The user's reported bug: httpx.RemoteProtocolError and similar
exceptions can stringify to "" — pre-fix the canvas rendered
"[A2A_ERROR] " with no detail. Verify the empty path now
produces an actionable message including the exception type
and the target URL."""
import a2a_client
# Subclass Exception with __str__ → "" to simulate the
# silent-exception variants without depending on a specific
# httpx version's behavior.
class _SilentRemoteProtocolError(Exception):
def __str__(self) -> str:
return ""
mock_client = _make_mock_client(post_exc=_SilentRemoteProtocolError())
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
result = await a2a_client.send_a2a_message(_TEST_PEER_ID, "task")
# Must NOT be just the bare prefix — that's the regression.
assert result != a2a_client._A2A_ERROR_PREFIX.strip()
assert result != f"{a2a_client._A2A_ERROR_PREFIX}"
# Must include the class name + something explanatory.
assert "_SilentRemoteProtocolError" in result
assert "no message" in result.lower()
# Target URL now constructed internally — assert it contains the peer_id
# and the proxy path, not the old hand-passed URL.
assert _TEST_PEER_ID in result
assert "/workspaces/" in result and "/a2a" in result
async def test_result_text_part_missing_text_key_returns_empty(self):
"""Part dict without 'text' key → falls back to '' (empty string returned)."""
import a2a_client
resp = _make_response(200, {
"result": {"parts": [{"kind": "text"}]} # no "text" key
})
mock_client = _make_mock_client(post_resp=resp)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
result = await a2a_client.send_a2a_message(_TEST_PEER_ID, "task")
# Returns "" (empty string — does not start with _A2A_ERROR_PREFIX)
assert result == ""
async def test_invalid_peer_id_short_circuits_without_http(self):
"""Malformed peer_id is rejected at the trust boundary — no POST.
Symmetric coverage with discover_peer's validation gate. Path-traversal
("../admin"), free-form labels ("ws-abc"), and empty strings all
return an _A2A_ERROR_PREFIX message identifying the bad input and
never reach the platform.
"""
import a2a_client
mock_client = _make_mock_client(post_resp=_make_response(200, {}))
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
for bad in ("", "ws-abc", "../admin", "not-a-uuid", "8dad3e29"):
result = await a2a_client.send_a2a_message(bad, "ping")
assert result.startswith(a2a_client._A2A_ERROR_PREFIX)
assert "invalid peer_id" in result
# No POST should have been issued for any of those.
mock_client.post.assert_not_called()
# ---------------------------------------------------------------------------
# send_a2a_message — transient-error retry behaviour
# ---------------------------------------------------------------------------
def _make_seq_mock_client(post_side_effect):
"""Build an AsyncClient mock whose .post() returns a different result
on each successive call (matching httpx.AsyncClient's per-request
semantics — each AsyncClient context-manager opens fresh in the
retry loop, so the sequence is observed across attempts).
A new AsyncClient context is opened for every retry attempt in the
SUT, so we route AsyncClient(...) to a single mock that hands back
the same client on every __aenter__ but the .post side-effect list
is shared and consumed sequentially across attempts.
"""
mock_client = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client.post = AsyncMock(side_effect=post_side_effect)
return mock_client
class TestSendA2AMessageRetry:
"""Verify auto-retry on transient transport errors (RemoteProtocolError,
ConnectError, ReadTimeout, etc.) up to _DELEGATE_MAX_ATTEMPTS times.
Application-level errors (HTTP-status errors, JSON-RPC error in
response body) MUST NOT be retried — they're deterministic and
re-trying just wastes wall-clock.
asyncio.sleep is patched to a no-op so tests don't actually wait
out the exponential backoff.
"""
async def test_retry_succeeds_after_two_remote_protocol_errors(self):
"""Two RemoteProtocolErrors followed by a 200 → returns the 200's text."""
import a2a_client
import httpx
success = _make_response(200, {"result": {"parts": [{"kind": "text", "text": "OK"}]}})
side_effects = [
httpx.RemoteProtocolError("Server disconnected"),
httpx.RemoteProtocolError("Server disconnected"),
success,
]
mock_client = _make_seq_mock_client(side_effects)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client), \
patch("a2a_client.asyncio.sleep", new=AsyncMock()):
result = await a2a_client.send_a2a_message(_TEST_PEER_ID, "ping")
assert result == "OK"
assert mock_client.post.await_count == 3
async def test_retry_succeeds_after_connect_error(self):
"""Single ConnectError then 200 → returns the 200's text."""
import a2a_client
import httpx
success = _make_response(200, {"result": {"parts": [{"kind": "text", "text": "OK"}]}})
side_effects = [
httpx.ConnectError("connection refused"),
success,
]
mock_client = _make_seq_mock_client(side_effects)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client), \
patch("a2a_client.asyncio.sleep", new=AsyncMock()):
result = await a2a_client.send_a2a_message(_TEST_PEER_ID, "ping")
assert result == "OK"
assert mock_client.post.await_count == 2
async def test_all_attempts_fail_returns_last_error(self):
"""5 RemoteProtocolErrors → returns the last error formatted with target URL."""
import a2a_client
import httpx
side_effects = [httpx.RemoteProtocolError("Server disconnected")] * 5
mock_client = _make_seq_mock_client(side_effects)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client), \
patch("a2a_client.asyncio.sleep", new=AsyncMock()):
result = await a2a_client.send_a2a_message(_TEST_PEER_ID, "ping")
assert mock_client.post.await_count == 5 # _DELEGATE_MAX_ATTEMPTS
assert result.startswith(a2a_client._A2A_ERROR_PREFIX)
assert "RemoteProtocolError" in result
# Target URL now constructed internally — assert it contains the peer_id
# and the proxy path, not the old hand-passed URL.
assert _TEST_PEER_ID in result
assert "/workspaces/" in result and "/a2a" in result
async def test_caps_at_max_attempts(self):
"""If transient errors keep coming, we MUST stop at _DELEGATE_MAX_ATTEMPTS,
not retry forever. Pin the exact attempt count so a future tweak to
the constant has to update this test in lockstep."""
import a2a_client
import httpx
side_effects = [httpx.ReadTimeout("timeout")] * 20 # way more than max
mock_client = _make_seq_mock_client(side_effects)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client), \
patch("a2a_client.asyncio.sleep", new=AsyncMock()):
result = await a2a_client.send_a2a_message(_TEST_PEER_ID, "ping")
assert mock_client.post.await_count == a2a_client._DELEGATE_MAX_ATTEMPTS
assert mock_client.post.await_count == 5
assert result.startswith(a2a_client._A2A_ERROR_PREFIX)
async def test_application_error_not_retried(self):
"""JSON-RPC error response (application-level) is deterministic —
retrying just wastes wall-clock. Must return on the first attempt."""
import a2a_client
resp = _make_response(200, {
"error": {"code": -32603, "message": "Internal error"}
})
mock_client = _make_seq_mock_client([resp, resp, resp])
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client), \
patch("a2a_client.asyncio.sleep", new=AsyncMock()):
result = await a2a_client.send_a2a_message(_TEST_PEER_ID, "ping")
assert mock_client.post.await_count == 1 # NO retry
assert "Internal error" in result
async def test_non_transient_exception_not_retried(self):
"""A non-httpx exception (programmer bug, JSON parse, etc.) must
not trigger retry — surface immediately so the bug is loud."""
import a2a_client
# A plain ValueError isn't in _TRANSIENT_HTTP_ERRORS.
side_effects = [ValueError("malformed something")] * 3
mock_client = _make_seq_mock_client(side_effects)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client), \
patch("a2a_client.asyncio.sleep", new=AsyncMock()):
result = await a2a_client.send_a2a_message(_TEST_PEER_ID, "ping")
assert mock_client.post.await_count == 1 # NO retry
assert result.startswith(a2a_client._A2A_ERROR_PREFIX)
assert "ValueError" in result
async def test_total_budget_caps_retry_loop(self, monkeypatch):
"""Total wall-clock budget caps the retry loop even if attempts
remain — protects against a string of 5×300s ReadTimeouts.
Simulate elapsed time advancing past the budget on attempt 2."""
import a2a_client
import httpx
side_effects = [httpx.ReadTimeout("timeout")] * 5
mock_client = _make_seq_mock_client(side_effects)
# Make time.monotonic() jump forward past the budget after the
# second attempt — the retry loop should detect the deadline
# and stop, even though _DELEGATE_MAX_ATTEMPTS is 5.
call_count = {"n": 0}
original_budget = a2a_client._DELEGATE_TOTAL_BUDGET_S
def fake_monotonic():
call_count["n"] += 1
# First call (deadline computation) → 0
# Subsequent calls → 0 until attempt 3, then jump past budget
if call_count["n"] <= 4:
return 0.0
return original_budget + 1.0
monkeypatch.setattr(a2a_client.time, "monotonic", fake_monotonic)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client), \
patch("a2a_client.asyncio.sleep", new=AsyncMock()):
result = await a2a_client.send_a2a_message(_TEST_PEER_ID, "ping")
# Stopped before exhausting all 5 attempts.
assert mock_client.post.await_count < 5
assert result.startswith(a2a_client._A2A_ERROR_PREFIX)
def test_delegate_backoff_seconds_grows_exponentially_with_jitter():
"""Schedule: ~1s, ~2s, ~4s, ~8s, then capped at 16s. ±25% jitter
means each delay falls in [base*0.75, base*1.25]."""
import a2a_client
# Run a bunch to sample the jitter distribution; assert each value
# falls in the expected window.
for attempt, base in [(0, 1.0), (1, 2.0), (2, 4.0), (3, 8.0), (4, 16.0), (10, 16.0)]:
for _ in range(20):
d = a2a_client._delegate_backoff_seconds(attempt)
assert d >= base * 0.75 - 1e-9, f"attempt {attempt}: {d} < lower"
assert d <= base * 1.25 + 1e-9, f"attempt {attempt}: {d} > upper"
# ---------------------------------------------------------------------------
# get_peers
# ---------------------------------------------------------------------------
class TestGetPeers:
async def test_success_returns_list_on_200(self):
"""200 response → returns the JSON list."""
import a2a_client
peers = [{"id": "ws-1", "name": "Alpha"}, {"id": "ws-2", "name": "Beta"}]
resp = _make_response(200, peers)
mock_client = _make_mock_client(get_resp=resp)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
result = await a2a_client.get_peers()
assert result == peers
async def test_non_200_returns_empty_list(self):
"""Non-200 response → returns []."""
import a2a_client
resp = _make_response(503, {"detail": "service unavailable"})
mock_client = _make_mock_client(get_resp=resp)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
result = await a2a_client.get_peers()
assert result == []
async def test_404_returns_empty_list(self):
"""404 response → returns []."""
import a2a_client
resp = _make_response(404, {"detail": "not found"})
mock_client = _make_mock_client(get_resp=resp)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
result = await a2a_client.get_peers()
assert result == []
async def test_exception_returns_empty_list(self):
"""Network exception → returns [] (exception swallowed)."""
import a2a_client
mock_client = _make_mock_client(get_exc=TimeoutError("timed out"))
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
result = await a2a_client.get_peers()
assert result == []
async def test_request_url_includes_workspace_id(self):
"""GET URL contains the WORKSPACE_ID."""
import a2a_client
resp = _make_response(200, [])
mock_client = _make_mock_client(get_resp=resp)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
await a2a_client.get_peers()
url = mock_client.get.call_args.args[0]
assert "peers" in url
async def test_request_sends_workspace_id_header(self):
"""GET /registry/:id/peers must send X-Workspace-ID header (Phase 30.6)."""
import a2a_client
resp = _make_response(200, [])
mock_client = _make_mock_client(get_resp=resp)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
await a2a_client.get_peers()
headers_sent = mock_client.get.call_args.kwargs.get("headers", {})
assert headers_sent.get("X-Workspace-ID") == a2a_client.WORKSPACE_ID
# ---------------------------------------------------------------------------
# get_peers_with_diagnostic — issue #2397
#
# Pin: an empty peer list MUST come with an actionable diagnostic on every
# non-200 + every transport failure. The bug was that get_peers swallowed
# every failure mode behind `return []`, leaving the agent's tool wrapper
# with no way to distinguish "you have no peers" from "auth broke" / "404
# from registry" / "platform 5xx" / "network timeout". Each of these
# requires a different operator action.
# ---------------------------------------------------------------------------
class TestGetPeersWithDiagnostic:
async def test_200_returns_peers_and_no_diagnostic(self):
"""200 with valid list → (peers, None). diagnostic stays None on success."""
import a2a_client
peers = [{"id": "ws-1", "name": "Alpha"}]
resp = _make_response(200, peers)
mock_client = _make_mock_client(get_resp=resp)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
result, diag = await a2a_client.get_peers_with_diagnostic()
assert result == peers
assert diag is None
async def test_200_empty_list_returns_no_diagnostic(self):
"""200 with [] → (peers=[], diag=None). Truly no peers is success, not error."""
import a2a_client
resp = _make_response(200, [])
mock_client = _make_mock_client(get_resp=resp)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
result, diag = await a2a_client.get_peers_with_diagnostic()
assert result == []
assert diag is None
async def test_401_returns_auth_diagnostic(self):
"""401 → diagnostic mentions auth + restart hint."""
import a2a_client
resp = _make_response(401, {"detail": "unauthorized"})
mock_client = _make_mock_client(get_resp=resp)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
result, diag = await a2a_client.get_peers_with_diagnostic()
assert result == []
assert diag is not None
assert "401" in diag
assert "Authentication" in diag or "authentication" in diag.lower()
async def test_403_returns_auth_diagnostic(self):
"""403 → same auth-failure diagnostic shape as 401."""
import a2a_client
resp = _make_response(403, {"detail": "forbidden"})
mock_client = _make_mock_client(get_resp=resp)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
result, diag = await a2a_client.get_peers_with_diagnostic()
assert result == []
assert diag is not None
assert "403" in diag
async def test_404_returns_registration_diagnostic(self):
"""404 → diagnostic tells operator the workspace ID is missing from the registry."""
import a2a_client
resp = _make_response(404, {"detail": "not found"})
mock_client = _make_mock_client(get_resp=resp)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
result, diag = await a2a_client.get_peers_with_diagnostic()
assert result == []
assert diag is not None
assert "404" in diag
assert "registered" in diag.lower() or "registration" in diag.lower()
async def test_500_returns_platform_error_diagnostic(self):
"""5xx → 'Platform error: HTTP <code>.'"""
import a2a_client
resp = _make_response(503, {"detail": "service unavailable"})
mock_client = _make_mock_client(get_resp=resp)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
result, diag = await a2a_client.get_peers_with_diagnostic()
assert result == []
assert diag is not None
assert "503" in diag
assert "Platform error" in diag or "platform error" in diag.lower()
async def test_network_exception_returns_unreachable_diagnostic(self):
"""httpx exception → diagnostic mentions PLATFORM_URL + the underlying error."""
import a2a_client
mock_client = _make_mock_client(get_exc=TimeoutError("connection timed out"))
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
result, diag = await a2a_client.get_peers_with_diagnostic()
assert result == []
assert diag is not None
assert "Cannot reach platform" in diag or "cannot reach" in diag.lower()
assert "timed out" in diag
async def test_200_with_non_list_body_returns_diagnostic(self):
"""200 but body is a dict → diagnostic flags shape mismatch (regression guard)."""
import a2a_client
resp = _make_response(200, {"oops": "should have been a list"})
mock_client = _make_mock_client(get_resp=resp)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
result, diag = await a2a_client.get_peers_with_diagnostic()
assert result == []
assert diag is not None
assert "list" in diag.lower()
async def test_get_peers_shim_preserves_bare_list_contract(self):
"""get_peers() still returns just list[dict] — no API break for non-tool callers."""
import a2a_client
peers = [{"id": "ws-1", "name": "Alpha"}]
resp = _make_response(200, peers)
mock_client = _make_mock_client(get_resp=resp)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
result = await a2a_client.get_peers()
# Must be a list, not a tuple — bare-list shim contract.
assert isinstance(result, list)
assert result == peers
# ---------------------------------------------------------------------------
# get_workspace_info
# ---------------------------------------------------------------------------
class TestGetWorkspaceInfo:
async def test_success_returns_dict_on_200(self):
"""200 response → returns the JSON dict."""
import a2a_client
info = {"id": "ws-test", "name": "Test Workspace", "status": "online"}
resp = _make_response(200, info)
mock_client = _make_mock_client(get_resp=resp)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
result = await a2a_client.get_workspace_info()
assert result == info
async def test_non_200_returns_error_dict(self):
"""Non-200 response → returns {'error': 'not found'}."""
import a2a_client
resp = _make_response(404, {"detail": "no such workspace"})
mock_client = _make_mock_client(get_resp=resp)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
result = await a2a_client.get_workspace_info()
assert result == {"error": "not found"}
async def test_500_returns_error_dict(self):
"""500 response → returns {'error': 'not found'}."""
import a2a_client
resp = _make_response(500, {"detail": "server error"})
mock_client = _make_mock_client(get_resp=resp)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
result = await a2a_client.get_workspace_info()
assert result == {"error": "not found"}
async def test_410_returns_removed_with_hint(self):
"""410 Gone (#2429) → distinct error 'removed' so callers can
prompt re-onboard instead of falling through to 'not found'.
Body shape passes through removed_at + the platform hint."""
import a2a_client
body = {
"error": "workspace removed",
"id": "ws-deleted-uuid",
"removed_at": "2026-04-30T12:00:00Z",
"hint": "Regenerate workspace + token from the canvas → Tokens tab",
}
resp = _make_response(410, body)
mock_client = _make_mock_client(get_resp=resp)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
result = await a2a_client.get_workspace_info()
assert result["error"] == "removed"
assert result["id"] == "ws-deleted-uuid"
assert result["removed_at"] == "2026-04-30T12:00:00Z"
assert "Regenerate" in result["hint"]
async def test_410_with_unparseable_body_falls_back_to_default_hint(self):
"""If the platform's 410 body isn't JSON for some reason, the
default hint still surfaces — the actionable signal must not
depend on body shape parity with the platform."""
import a2a_client
resp = MagicMock()
resp.status_code = 410
resp.json = MagicMock(side_effect=ValueError("not json"))
mock_client = _make_mock_client(get_resp=resp)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
result = await a2a_client.get_workspace_info()
assert result["error"] == "removed"
assert result["id"] == a2a_client.WORKSPACE_ID
assert result["removed_at"] is None
assert "Regenerate" in result["hint"]
async def test_exception_returns_error_dict_with_message(self):
"""Network exception → returns {'error': '<exception message>'}."""
import a2a_client
exc = RuntimeError("network failure")
mock_client = _make_mock_client(get_exc=exc)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
result = await a2a_client.get_workspace_info()
assert "error" in result
assert "network failure" in result["error"]
async def test_request_url_includes_workspaces_path(self):
"""GET URL contains /workspaces/."""
import a2a_client
resp = _make_response(200, {})
mock_client = _make_mock_client(get_resp=resp)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
await a2a_client.get_workspace_info()
url = mock_client.get.call_args.args[0]
assert "/workspaces/" in url