Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c9be72a99f | |||
| 8e9aa7af48 | |||
| 3fe7dabb65 | |||
| 61dc421975 | |||
| ae1fd648d5 | |||
| 5f7bba3e96 |
@@ -0,0 +1,108 @@
|
||||
"""ASV benchmarks for Server HTTP client.
|
||||
|
||||
Measures construction, health check, version retrieval, and
|
||||
pagination throughput using mocked HTTP transport.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
|
||||
# Ensure the local source tree is importable.
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
import cleveragents # noqa: E402
|
||||
|
||||
importlib.reload(cleveragents)
|
||||
|
||||
from cleveragents.client.http_client import ( # noqa: E402
|
||||
PageResult,
|
||||
ServerHttpClient,
|
||||
_backoff_delay,
|
||||
_redact_headers,
|
||||
)
|
||||
|
||||
|
||||
def _mock_response(
|
||||
status: int = 200,
|
||||
body: dict | list | None = None,
|
||||
) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
status_code=status,
|
||||
json=body,
|
||||
headers={},
|
||||
request=httpx.Request("GET", "http://mock"),
|
||||
)
|
||||
|
||||
|
||||
class ClientConstructionSuite:
|
||||
"""Benchmark ServerHttpClient construction overhead."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_create_default(self) -> None:
|
||||
ServerHttpClient()
|
||||
|
||||
def time_create_with_token(self) -> None:
|
||||
ServerHttpClient(
|
||||
base_url="https://x.com",
|
||||
api_token="tok_benchmark1234567890",
|
||||
)
|
||||
|
||||
|
||||
class ClientHealthSuite:
|
||||
"""Benchmark health check throughput."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
self.client = ServerHttpClient()
|
||||
self.healthy = _mock_response(200, {"status": "healthy"})
|
||||
|
||||
def time_health_check(self) -> None:
|
||||
with patch("cleveragents.client.http_client.httpx.request") as m:
|
||||
m.return_value = self.healthy
|
||||
self.client.health_check()
|
||||
|
||||
|
||||
class ClientVersionSuite:
|
||||
"""Benchmark version retrieval throughput."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
self.client = ServerHttpClient()
|
||||
self.resp = _mock_response(200, {"version": "1.0.0"})
|
||||
|
||||
def time_get_version(self) -> None:
|
||||
with patch("cleveragents.client.http_client.httpx.request") as m:
|
||||
m.return_value = self.resp
|
||||
self.client.get_version()
|
||||
|
||||
|
||||
class HelperSuite:
|
||||
"""Benchmark helper functions."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
self.headers = {
|
||||
"Authorization": "Bearer tok_secret123abcdefghij",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
def time_redact_headers(self) -> None:
|
||||
_redact_headers(self.headers)
|
||||
|
||||
def time_backoff_delay(self) -> None:
|
||||
_backoff_delay(3, 0.5, 30.0)
|
||||
|
||||
def time_page_result_creation(self) -> None:
|
||||
PageResult(items=[{"id": "1"}], page=1, per_page=10, total=1, has_next=False)
|
||||
@@ -0,0 +1,101 @@
|
||||
"""ASV benchmarks for Remote Project client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
import cleveragents # noqa: E402
|
||||
|
||||
importlib.reload(cleveragents)
|
||||
|
||||
from cleveragents.client.http_client import ( # noqa: E402
|
||||
PageResult,
|
||||
ServerHttpClient,
|
||||
)
|
||||
from cleveragents.client.remote_project import ( # noqa: E402
|
||||
RemoteProject,
|
||||
RemoteProjectClient,
|
||||
)
|
||||
|
||||
_ITEMS = [
|
||||
{"id": f"rp{i}", "name": f"proj-{i}", "alias": f"p{i}", "description": f"Proj {i}"}
|
||||
for i in range(20)
|
||||
]
|
||||
|
||||
|
||||
def _build() -> RemoteProjectClient:
|
||||
mock = MagicMock(spec=ServerHttpClient)
|
||||
mock.list_endpoint.return_value = PageResult(
|
||||
items=_ITEMS, page=1, per_page=50, total=20, has_next=False
|
||||
)
|
||||
return RemoteProjectClient(mock)
|
||||
|
||||
|
||||
class ListProjectsSuite:
|
||||
"""Benchmark project listing."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
self.client = _build()
|
||||
|
||||
def time_list_cold(self) -> None:
|
||||
c = _build()
|
||||
c.list_projects()
|
||||
|
||||
def time_list_cached(self) -> None:
|
||||
self.client.list_projects()
|
||||
|
||||
|
||||
class ResolveProjectSuite:
|
||||
"""Benchmark project resolution."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
self.client = _build()
|
||||
self.client.list_projects()
|
||||
|
||||
def time_resolve_by_name(self) -> None:
|
||||
self.client.get_project("proj-10")
|
||||
|
||||
def time_resolve_by_alias(self) -> None:
|
||||
self.client.get_project("p10")
|
||||
|
||||
|
||||
class CacheSuite:
|
||||
"""Benchmark cache operations."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_invalidate_all(self) -> None:
|
||||
c = _build()
|
||||
c.list_projects()
|
||||
c.invalidate_cache()
|
||||
|
||||
def time_invalidate_ns(self) -> None:
|
||||
c = _build()
|
||||
c.list_projects()
|
||||
c.invalidate_cache("default")
|
||||
|
||||
|
||||
class RemoteProjectCreationSuite:
|
||||
"""Benchmark RemoteProject creation."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_create(self) -> None:
|
||||
RemoteProject(
|
||||
project_id="rp1",
|
||||
name="test",
|
||||
namespace="default",
|
||||
alias="t",
|
||||
description="Test project",
|
||||
)
|
||||
@@ -0,0 +1,87 @@
|
||||
"""ASV benchmarks for Plan Sync client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
import cleveragents # noqa: E402
|
||||
|
||||
importlib.reload(cleveragents)
|
||||
|
||||
from cleveragents.client.http_client import ServerHttpClient # noqa: E402
|
||||
from cleveragents.client.sync_client import ( # noqa: E402
|
||||
ExecutionResult,
|
||||
PlanSyncClient,
|
||||
SyncScope,
|
||||
SyncSummary,
|
||||
)
|
||||
|
||||
|
||||
def _mock_response(status: int = 200, body: dict | None = None) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
status_code=status,
|
||||
json=body,
|
||||
headers={},
|
||||
request=httpx.Request("GET", "http://mock"),
|
||||
)
|
||||
|
||||
|
||||
class SyncScopeSuite:
|
||||
"""Benchmark SyncScope operations."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_active_types_all(self) -> None:
|
||||
SyncScope().active_types()
|
||||
|
||||
def time_active_types_partial(self) -> None:
|
||||
SyncScope(actions=True, skills=False, tools=True, projects=False).active_types()
|
||||
|
||||
|
||||
class SyncOperationSuite:
|
||||
"""Benchmark sync operations."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
self.mock_http = MagicMock(spec=ServerHttpClient)
|
||||
self.mock_http.request.return_value = _mock_response(200, {"id": "srv"})
|
||||
self.client = PlanSyncClient(self.mock_http)
|
||||
self.items = [{"id": f"item-{i}"} for i in range(10)]
|
||||
|
||||
def time_sync_10_items(self) -> None:
|
||||
self.client.sync(self.items, "actions")
|
||||
|
||||
def time_sync_dry_run(self) -> None:
|
||||
self.client.sync(self.items, "actions", dry_run=True)
|
||||
|
||||
|
||||
class SyncSummarySuite:
|
||||
"""Benchmark SyncSummary creation."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_summary_creation(self) -> None:
|
||||
SyncSummary(created=5, updated=3, skipped=1, errors=0)
|
||||
|
||||
def time_total_processed(self) -> None:
|
||||
s = SyncSummary(created=5, updated=3, skipped=1, errors=1)
|
||||
_ = s.total_processed
|
||||
|
||||
|
||||
class ExecutionResultSuite:
|
||||
"""Benchmark ExecutionResult creation."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_create_result(self) -> None:
|
||||
ExecutionResult(plan_id="p1", server_plan_id="sp1", status="done", message="ok")
|
||||
@@ -0,0 +1,86 @@
|
||||
"""ASV benchmarks for WebSocket client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
import cleveragents # noqa: E402
|
||||
|
||||
importlib.reload(cleveragents)
|
||||
|
||||
from cleveragents.a2a.models import A2aEvent # noqa: E402
|
||||
from cleveragents.client.ws_client import ( # noqa: E402
|
||||
ConnectionState,
|
||||
EventDeduplicator,
|
||||
WebSocketClient,
|
||||
_ws_backoff_delay,
|
||||
)
|
||||
|
||||
|
||||
class DeduplicatorSuite:
|
||||
"""Benchmark EventDeduplicator throughput."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
self.dedup = EventDeduplicator(capacity=1000)
|
||||
|
||||
def time_is_duplicate_miss(self) -> None:
|
||||
d = EventDeduplicator(capacity=1000)
|
||||
for i in range(100):
|
||||
d.is_duplicate(f"evt-{i}")
|
||||
|
||||
def time_is_duplicate_hit(self) -> None:
|
||||
d = EventDeduplicator(capacity=1000)
|
||||
d.is_duplicate("evt-x")
|
||||
for _ in range(100):
|
||||
d.is_duplicate("evt-x")
|
||||
|
||||
|
||||
class EventProcessingSuite:
|
||||
"""Benchmark event processing throughput."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
self.client = WebSocketClient()
|
||||
self.client._state.connected = True
|
||||
self.client._running = True
|
||||
self.client.subscribe(lambda e: None)
|
||||
|
||||
def time_process_event(self) -> None:
|
||||
c = WebSocketClient()
|
||||
c._state.connected = True
|
||||
c._running = True
|
||||
c.subscribe(lambda e: None)
|
||||
for i in range(100):
|
||||
event = A2aEvent(event_id=f"bench-{i}", event_type="plan.status")
|
||||
c.process_event(event)
|
||||
|
||||
|
||||
class BackoffSuite:
|
||||
"""Benchmark backoff computation."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_backoff_delay(self) -> None:
|
||||
for i in range(10):
|
||||
_ws_backoff_delay(i, 1.0, 60.0)
|
||||
|
||||
|
||||
class ConnectionStateSuite:
|
||||
"""Benchmark ConnectionState operations."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_create_and_reset(self) -> None:
|
||||
s = ConnectionState()
|
||||
s.connected = True
|
||||
s.last_event_id = "ev1"
|
||||
s.reset()
|
||||
@@ -0,0 +1,71 @@
|
||||
# Server HTTP Client
|
||||
|
||||
The `ServerHttpClient` provides communication with a remote CleverAgents
|
||||
server instance. It wraps [httpx](https://www.python-httpx.org/) and
|
||||
handles health checks, version negotiation, pagination, retry with
|
||||
exponential backoff, and request/response logging with auth-header
|
||||
redaction.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Environment variable | Settings field | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `CLEVERAGENTS_SERVER_BASE_URL` | `server_base_url` | `None` | Base URL of the remote server (e.g. `https://server.example.com`) |
|
||||
| `CLEVERAGENTS_SERVER_API_TOKEN` | `server_api_token` | `None` | Bearer token for server authentication |
|
||||
| `CLEVERAGENTS_SERVER_TLS_VERIFY` | `server_tls_verify` | `True` | Verify TLS certificates; set to `False` only for development |
|
||||
| `CLEVERAGENTS_SERVER_REQUEST_TIMEOUT` | `server_request_timeout` | `30.0` | Per-request timeout in seconds |
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
from cleveragents.client.http_client import create_client_from_settings
|
||||
|
||||
client = create_client_from_settings()
|
||||
|
||||
# Health check
|
||||
is_healthy = client.health_check()
|
||||
|
||||
# Version negotiation
|
||||
version = client.get_version()
|
||||
negotiated = client.negotiate_version("1.0")
|
||||
|
||||
# Paginated list endpoint
|
||||
page = client.list_endpoint("/plans", page=1, per_page=20)
|
||||
print(page.items, page.has_next)
|
||||
```
|
||||
|
||||
## Connection Errors
|
||||
|
||||
| Exception | Cause |
|
||||
|---|---|
|
||||
| `ServerConnectionError` | Cannot reach the server, non-retryable HTTP error, or authentication failure |
|
||||
| `ServerTimeoutError` | Request exceeded the configured timeout |
|
||||
| `ServerVersionMismatchError` | Client and server protocol versions are incompatible |
|
||||
| `A2aNotAvailableError` | Server returned 503 Service Unavailable |
|
||||
|
||||
## Retry Policy
|
||||
|
||||
Idempotent methods (`GET`, `HEAD`, `OPTIONS`, `PUT`, `DELETE`) are
|
||||
automatically retried up to 3 times with exponential backoff on status
|
||||
codes 429, 500, 502, and 504. Non-idempotent methods (`POST`, `PATCH`)
|
||||
are never retried automatically.
|
||||
|
||||
## TLS Verification
|
||||
|
||||
When `server_tls_verify` is set to `False` a warning is logged at
|
||||
client creation time. This is intended only for local development
|
||||
against self-signed certificates; production deployments must always
|
||||
verify TLS.
|
||||
|
||||
## Health Check Failure
|
||||
|
||||
When `health_check()` encounters a connection or timeout error it
|
||||
returns `False` rather than raising. This allows callers to use it as
|
||||
a boolean probe without try/except.
|
||||
|
||||
## Version Negotiation Failure
|
||||
|
||||
`negotiate_version()` raises `ServerVersionMismatchError` when the
|
||||
server does not return a negotiated version. The exception includes
|
||||
both the requested client version and the list of versions the server
|
||||
supports.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Remote Project Support
|
||||
|
||||
The `RemoteProjectClient` resolves and executes projects hosted on a
|
||||
remote CleverAgents server. It provides namespace-aware project
|
||||
resolution, execution request wiring, and a TTL-based project cache.
|
||||
|
||||
## Project Resolution
|
||||
|
||||
Projects are resolved by name using the following fallback order:
|
||||
|
||||
1. Exact match in the specified namespace
|
||||
2. Exact match in the `default` namespace
|
||||
3. `ResourceNotFoundError` with an explanatory message
|
||||
|
||||
Server aliases are also supported — a project can be referenced by its
|
||||
alias rather than its full namespace-qualified name.
|
||||
|
||||
## Remote Execution
|
||||
|
||||
```python
|
||||
from cleveragents.client.remote_project import RemoteProjectClient
|
||||
|
||||
client = RemoteProjectClient(http_client)
|
||||
project = client.resolve_project("my-project", namespace="org")
|
||||
result = client.request_execution(project.project_id)
|
||||
```
|
||||
|
||||
## Caching
|
||||
|
||||
Resolved projects are cached with a configurable TTL (default 5 minutes)
|
||||
to minimize repeated server calls. Cache entries are keyed by
|
||||
`(name, namespace)` tuples.
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `invalidate_cache()` | Clear all cached entries |
|
||||
| `invalidate_cache(namespace="org")` | Clear cache for a specific namespace |
|
||||
|
||||
## CLI Scaffolding
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `agents project list --remote` | List projects available on the server |
|
||||
| `agents plan use --remote` | Create a plan targeting a remote project |
|
||||
|
||||
Both commands require a configured server connection (see
|
||||
[HTTP client configuration](server_client_http.md#configuration)).
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Exception | Cause |
|
||||
|---|---|
|
||||
| `ResourceNotFoundError` | Project not found on the server |
|
||||
| `ServerConnectionError` | Server unreachable |
|
||||
| `AuthorizationError` | User lacks access to the requested project or namespace |
|
||||
@@ -0,0 +1,67 @@
|
||||
# Server Sync and Remote Execution
|
||||
|
||||
The `PlanSyncClient` synchronizes local resources (actions, skills, tools,
|
||||
projects) with a remote CleverAgents server and supports remote plan
|
||||
execution, apply, and status queries.
|
||||
|
||||
## Configuration
|
||||
|
||||
Sync uses the same server connection configured via the
|
||||
[HTTP client](server_client_http.md) settings (`CLEVERAGENTS_SERVER_BASE_URL`,
|
||||
`CLEVERAGENTS_SERVER_API_TOKEN`).
|
||||
|
||||
## Sync Scope
|
||||
|
||||
By default, sync transfers the minimal set of resources required for
|
||||
a plan. Use scope flags to control which entity types are synchronized:
|
||||
|
||||
| Flag | Entity type |
|
||||
|---|---|
|
||||
| `--actions` | Actions |
|
||||
| `--skills` | Skills |
|
||||
| `--tools` | Tools |
|
||||
| `--projects` | Projects |
|
||||
|
||||
## Conflict Resolution
|
||||
|
||||
When a local entity and a server entity share the same name but differ
|
||||
in content, the conflict resolution policy determines the outcome:
|
||||
|
||||
| Policy | Behaviour |
|
||||
|---|---|
|
||||
| `local_wins` | Local version overwrites the server version |
|
||||
| `server_wins` | Server version is kept; local changes are discarded |
|
||||
|
||||
If no policy is set and a conflict is detected, the client raises a
|
||||
`ValueError` with an explanatory message.
|
||||
|
||||
## Dry-Run Mode
|
||||
|
||||
Pass `dry_run=True` to `sync_all()` to preview what would be
|
||||
synchronized without executing any mutations on the server.
|
||||
|
||||
## Sync Summary
|
||||
|
||||
After a sync operation the client returns a `SyncSummary` with counts:
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `created` | Items created on the server |
|
||||
| `updated` | Items updated on the server |
|
||||
| `skipped` | Items unchanged (already in sync) |
|
||||
| `errors` | Items that failed to sync |
|
||||
|
||||
## Remote Execution
|
||||
|
||||
```python
|
||||
from cleveragents.client.sync_client import PlanSyncClient
|
||||
|
||||
sync = PlanSyncClient(http_client)
|
||||
result = sync.execute_plan("PLAN001")
|
||||
print(result.status, result.remote_plan_id)
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
Sync and execution errors propagate from the underlying HTTP client.
|
||||
See [Server HTTP Client — Connection Errors](server_client_http.md#connection-errors).
|
||||
@@ -0,0 +1,64 @@
|
||||
# Server WebSocket Client
|
||||
|
||||
The `WebSocketClient` receives real-time plan update events from a
|
||||
CleverAgents server over a WebSocket connection. It supports automatic
|
||||
reconnection with exponential backoff, heartbeat handling, resume from
|
||||
last event ID, and event de-duplication.
|
||||
|
||||
## Event Types
|
||||
|
||||
| Event type | Description |
|
||||
|---|---|
|
||||
| `plan.status` | Plan lifecycle status change (e.g. running → completed) |
|
||||
| `plan.progress` | Progress percentage or phase update |
|
||||
| `plan.log` | Log stream entry from the server |
|
||||
| `heartbeat` | Server heartbeat ping (resets reconnect counter) |
|
||||
|
||||
## Reconnect Policy
|
||||
|
||||
When the connection drops the client reconnects automatically using
|
||||
exponential backoff. Parameters are configurable:
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|---|---|---|
|
||||
| `backoff_base` | `1.0` | Base delay in seconds |
|
||||
| `backoff_max` | `30.0` | Maximum delay in seconds |
|
||||
| `max_reconnects` | `10` | Maximum reconnect attempts before giving up |
|
||||
|
||||
Successful heartbeat reception resets the reconnect attempt counter.
|
||||
|
||||
## Resume from Last Event ID
|
||||
|
||||
On reconnect the client sends the last received `event_id` to the
|
||||
server so that missed events can be replayed. This ensures no updates
|
||||
are lost during transient disconnections.
|
||||
|
||||
## Event De-duplication
|
||||
|
||||
The `EventDeduplicator` tracks recently seen `event_id` values in an
|
||||
LRU-bounded set. Duplicate events (same `event_id` received more than
|
||||
once) are silently dropped.
|
||||
|
||||
## Event Version Negotiation
|
||||
|
||||
The client sends its supported event schema version at connection time.
|
||||
If the server returns an incompatible version the connection is rejected
|
||||
with a `ServerVersionMismatchError`.
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
from cleveragents.client.ws_client import WebSocketClient
|
||||
|
||||
ws = WebSocketClient(
|
||||
base_url="wss://server.example.com",
|
||||
api_token="tok_...",
|
||||
)
|
||||
|
||||
def on_event(event):
|
||||
print(event["event_type"], event.get("data"))
|
||||
|
||||
ws.subscribe("PLAN001", on_event)
|
||||
# Later:
|
||||
ws.close()
|
||||
```
|
||||
@@ -0,0 +1,131 @@
|
||||
@phase2 @client @sync
|
||||
Feature: Plan sync and remote execution
|
||||
As a developer
|
||||
I want to sync local plans with a remote server
|
||||
So that I can execute plans remotely and keep state in sync
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sync scope
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: SyncScope returns all types when all flags are true
|
||||
Given a SyncScope with all flags enabled
|
||||
Then the active types should be "actions,skills,tools,projects"
|
||||
|
||||
Scenario: SyncScope returns only selected types
|
||||
Given a SyncScope with only actions and tools enabled
|
||||
Then the active types should be "actions,tools"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sync operations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: Sync creates new items on the server
|
||||
Given a PlanSyncClient with a mock HTTP client
|
||||
And a list of 2 new items without server_id
|
||||
When I sync the items as "actions"
|
||||
Then the sync summary should show 2 created and 0 updated
|
||||
And the sync summary should not be a dry run
|
||||
|
||||
Scenario: Sync updates existing items on the server
|
||||
Given a PlanSyncClient with a mock HTTP client
|
||||
And a list of 2 existing items with server_id and newer version
|
||||
When I sync the items as "tools"
|
||||
Then the sync summary should show 0 created and 2 updated
|
||||
|
||||
Scenario: Sync skips items with matching version
|
||||
Given a PlanSyncClient with a mock HTTP client
|
||||
And a list of 1 existing items with server_id and same version
|
||||
When I sync the items as "skills"
|
||||
Then the sync summary should show 0 created and 0 updated and 1 skipped
|
||||
|
||||
Scenario: Sync dry-run does not mutate server
|
||||
Given a PlanSyncClient with a mock HTTP client
|
||||
And a list of 2 new items without server_id
|
||||
When I sync the items as "actions" with dry_run true
|
||||
Then the sync summary should show 2 created and 0 updated
|
||||
And the sync summary should be a dry run
|
||||
|
||||
Scenario: Sync handles items missing id as errors
|
||||
Given a PlanSyncClient with a mock HTTP client
|
||||
And a list with one item missing id
|
||||
When I sync the items as "actions"
|
||||
Then the sync summary should show 0 created and 0 updated and 1 error
|
||||
|
||||
Scenario: Sync server_wins policy skips when server version is newer
|
||||
Given a PlanSyncClient with server_wins conflict policy
|
||||
And a list of 1 existing items where server version is newer
|
||||
When I sync the items as "tools"
|
||||
Then the sync summary should show 0 created and 0 updated and 1 skipped
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sync all
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: Sync all respects scope flags
|
||||
Given a PlanSyncClient with a mock HTTP client
|
||||
And resources for actions and tools
|
||||
When I sync all with a scope limited to actions only
|
||||
Then the sync result should contain actions but not tools
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Remote execution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: Execute plan returns execution result
|
||||
Given a PlanSyncClient with a mock HTTP client for execution
|
||||
When I execute plan "plan-001"
|
||||
Then the execution result plan_id should be "plan-001"
|
||||
And the execution result status should be "submitted"
|
||||
|
||||
Scenario: Apply plan returns execution result
|
||||
Given a PlanSyncClient with a mock HTTP client for execution
|
||||
When I apply plan "plan-002"
|
||||
Then the execution result plan_id should be "plan-002"
|
||||
And the execution result status should be "applying"
|
||||
|
||||
Scenario: Get plan status returns status dict
|
||||
Given a PlanSyncClient with a mock HTTP client for status
|
||||
When I get status for server plan "srv-001"
|
||||
Then the plan status should contain phase "running"
|
||||
|
||||
Scenario: Execute plan with empty id raises ValueError
|
||||
Given a PlanSyncClient with a mock HTTP client
|
||||
When I execute plan with empty id
|
||||
Then a ValueError should be raised for empty sync plan_id
|
||||
|
||||
Scenario: Apply plan with empty id raises ValueError
|
||||
Given a PlanSyncClient with a mock HTTP client
|
||||
When I apply plan with empty id
|
||||
Then a ValueError should be raised for empty sync plan_id
|
||||
|
||||
Scenario: Get plan status with empty id raises ValueError
|
||||
Given a PlanSyncClient with a mock HTTP client
|
||||
When I get status with empty server plan id
|
||||
Then a ValueError should be raised for empty sync plan_id
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Conflict policy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: Conflict policy can be changed at runtime
|
||||
Given a PlanSyncClient with local_wins conflict policy
|
||||
When I change the conflict policy to server_wins
|
||||
Then the conflict policy should be server_wins
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SyncSummary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: SyncSummary total_processed counts all fields
|
||||
Given a SyncSummary with 2 created 1 updated 1 skipped 1 errors
|
||||
Then the total processed should be 5
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ExecutionResult attributes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: ExecutionResult stores all attributes
|
||||
Given an ExecutionResult with plan_id "p1" server_plan_id "sp1" status "done" message "ok"
|
||||
Then the execution result message should be "ok"
|
||||
And the execution result server_plan_id should be "sp1"
|
||||
@@ -0,0 +1,115 @@
|
||||
@phase2 @client @remote-project
|
||||
Feature: Remote project support
|
||||
As a developer
|
||||
I want to access and execute projects hosted on a remote server
|
||||
So that I can use remote resources and run plans on the server
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Project listing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: List remote projects from server
|
||||
Given a RemoteProjectClient with mock projects in default namespace
|
||||
When I list remote projects in namespace "default"
|
||||
Then I should receive 2 remote projects
|
||||
And the first remote project name should be "project-alpha"
|
||||
|
||||
Scenario: List remote projects uses cache on second call
|
||||
Given a RemoteProjectClient with mock projects in default namespace
|
||||
When I list remote projects in namespace "default"
|
||||
And I list remote projects in namespace "default" again
|
||||
Then the HTTP client should have been called once for project listing
|
||||
|
||||
Scenario: List remote projects with force_refresh bypasses cache
|
||||
Given a RemoteProjectClient with mock projects in default namespace
|
||||
When I list remote projects in namespace "default"
|
||||
And I list remote projects with force_refresh
|
||||
Then the HTTP client should have been called twice for project listing
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Project resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: Get project by name
|
||||
Given a RemoteProjectClient with mock projects in default namespace
|
||||
When I get remote project "project-alpha" in namespace "default"
|
||||
Then the resolved project name should be "project-alpha"
|
||||
And the resolved project namespace should be "default"
|
||||
|
||||
Scenario: Get project by alias
|
||||
Given a RemoteProjectClient with mock projects with aliases
|
||||
When I get remote project "alpha" in namespace "default"
|
||||
Then the resolved project name should be "project-alpha"
|
||||
|
||||
Scenario: Get project not found raises ResourceNotFoundError
|
||||
Given a RemoteProjectClient with mock projects in default namespace
|
||||
When I get remote project "nonexistent" in namespace "default" expecting error
|
||||
Then a ResourceNotFoundError should be raised for remote project
|
||||
|
||||
Scenario: Get project with empty name raises ValueError
|
||||
Given a RemoteProjectClient with mock projects in default namespace
|
||||
When I get remote project with empty name
|
||||
Then a ValueError should be raised for remote project name
|
||||
|
||||
Scenario: Resolve project falls back to default namespace
|
||||
Given a RemoteProjectClient with mock projects in default namespace
|
||||
When I resolve project "project-alpha" in namespace "custom" with fallback
|
||||
Then the resolved project name should be "project-alpha"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Execution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: Request execution of remote project
|
||||
Given a RemoteProjectClient with mock execution endpoint
|
||||
When I request execution of remote project "proj-001"
|
||||
Then the execution response should contain status "submitted"
|
||||
|
||||
Scenario: Request execution with plan name
|
||||
Given a RemoteProjectClient with mock execution endpoint
|
||||
When I request remote project "proj-001" execution using plan "my-plan"
|
||||
Then the execution response should contain status "submitted"
|
||||
|
||||
Scenario: Request execution with empty project_id raises ValueError
|
||||
Given a RemoteProjectClient with mock execution endpoint
|
||||
When I request execution with empty project_id
|
||||
Then a ValueError should be raised for remote project_id
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cache management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: Invalidate cache for specific namespace
|
||||
Given a RemoteProjectClient with mock projects in default namespace
|
||||
When I list remote projects in namespace "default"
|
||||
And I invalidate cache for namespace "default"
|
||||
Then the remote project cache size should be 0
|
||||
|
||||
Scenario: Invalidate all cache
|
||||
Given a RemoteProjectClient with mock projects in default namespace
|
||||
When I list remote projects in namespace "default"
|
||||
And I invalidate all cache
|
||||
Then the remote project cache size should be 0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RemoteProject attributes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: RemoteProject stores all attributes
|
||||
Given a RemoteProject with id "rp1" name "test" namespace "ns" alias "t" description "desc"
|
||||
Then the remote project project_id should be "rp1"
|
||||
And the remote project alias should be "t"
|
||||
And the remote project description should be "desc"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cache TTL
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: Cache TTL can be configured
|
||||
Given a RemoteProjectClient with cache_ttl 60.0
|
||||
Then the client cache_ttl should be 60.0
|
||||
|
||||
Scenario: Resolve project with empty name raises ValueError
|
||||
Given a RemoteProjectClient with mock projects in default namespace
|
||||
When I resolve project with empty name
|
||||
Then a ValueError should be raised for remote resolve name
|
||||
@@ -0,0 +1,175 @@
|
||||
@phase2 @client @http
|
||||
Feature: Server HTTP client
|
||||
As a developer
|
||||
I want an HTTP client for server communication
|
||||
So that I can perform health checks, version negotiation, and paginated queries
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Construction and configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: Create client with default settings
|
||||
Given a ServerHttpClient with default settings
|
||||
Then the client base_url should be "http://localhost:8080"
|
||||
And the client tls_verify should be true
|
||||
And the client timeout should be 30.0
|
||||
|
||||
Scenario: Create client with custom settings
|
||||
Given a ServerHttpClient with base_url "https://example.com" and token "tok_test123abcdefghij"
|
||||
Then the client base_url should be "https://example.com"
|
||||
And the client tls_verify should be true
|
||||
|
||||
Scenario: TLS verification disabled logs warning
|
||||
Given a ServerHttpClient with tls_verify disabled
|
||||
Then the client tls_verify should be false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Health check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: Health check returns true for healthy server
|
||||
Given a ServerHttpClient with a mock healthy server
|
||||
When I call health_check on the http client
|
||||
Then the health check result should be true
|
||||
|
||||
Scenario: Health check returns false for unreachable server
|
||||
Given a ServerHttpClient with an unreachable server
|
||||
When I call health_check on the http client
|
||||
Then the health check result should be false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Version retrieval
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: Get version returns server version string
|
||||
Given a ServerHttpClient with a mock version server returning "2.1.0"
|
||||
When I call get_version on the http client
|
||||
Then the version result should be "2.1.0"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Version negotiation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: Negotiate version succeeds with compatible version
|
||||
Given a ServerHttpClient with a mock negotiate server returning "1.0"
|
||||
When I call negotiate_version on the http client
|
||||
Then the http client negotiated version should be "1.0"
|
||||
|
||||
Scenario: Negotiate version fails when server returns empty
|
||||
Given a ServerHttpClient with a mock negotiate server returning empty
|
||||
When I call negotiate_version on the http client expecting error
|
||||
Then a ServerVersionMismatchError should be raised from http client
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pagination
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: List endpoint parses paginated object response
|
||||
Given a ServerHttpClient with a mock paginated endpoint returning 3 items
|
||||
When I call list_endpoint with page 1 per_page 10
|
||||
Then the page result should have 3 items
|
||||
And the page result should have total 3
|
||||
|
||||
Scenario: List endpoint parses list response
|
||||
Given a ServerHttpClient with a mock list endpoint returning 5 items
|
||||
When I call list_endpoint with page 1 per_page 10
|
||||
Then the page result should have 5 items
|
||||
And the page result has_next should be false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: Server 503 raises A2aNotAvailableError
|
||||
Given a ServerHttpClient with a mock server returning 503
|
||||
When I call get_version and capture the error
|
||||
Then an A2aNotAvailableError should be raised from the http client
|
||||
|
||||
Scenario: Server 502 raises ServerConnectionError
|
||||
Given a ServerHttpClient with a mock server returning 502
|
||||
When I call get_version and capture the error
|
||||
Then a ServerConnectionError should be raised from the http client
|
||||
|
||||
Scenario: Server 401 raises ServerConnectionError with auth message
|
||||
Given a ServerHttpClient with a mock server returning 401
|
||||
When I call get_version and capture the error
|
||||
Then a ServerConnectionError should be raised from the http client
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Retry logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: Retries on 500 for idempotent GET
|
||||
Given a ServerHttpClient with a mock server that fails twice then succeeds
|
||||
When I call get_version on the http client
|
||||
Then the version result should be "1.0.0"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth header redaction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: Auth headers are redacted in log output
|
||||
Given request headers with an Authorization bearer token
|
||||
When I redact the headers
|
||||
Then the Authorization value should be "***REDACTED***"
|
||||
And the Accept header should not be redacted
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Client lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: Close is a safe no-op
|
||||
Given a ServerHttpClient with default settings
|
||||
When I call close on the http client
|
||||
Then no error should be raised from the http client
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Factory from settings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: create_client_from_settings raises when URL not configured
|
||||
When I call create_client_from_settings without a configured URL
|
||||
Then a ValueError should be raised with URL configuration message from factory
|
||||
|
||||
Scenario: create_client_from_settings creates client when URL is set
|
||||
When I call create_client_from_settings with a configured URL
|
||||
Then a ServerHttpClient should be returned
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Client exception attributes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: ServerConnectionError carries url and cause attributes
|
||||
Given a ServerConnectionError with url "https://x.com" and cause
|
||||
Then the error url attribute should be "https://x.com"
|
||||
And the error cause attribute should not be None
|
||||
|
||||
Scenario: ServerTimeoutError carries timeout_seconds attribute
|
||||
Given a ServerTimeoutError with 10.0 seconds
|
||||
Then the error timeout_seconds attribute should be 10.0
|
||||
|
||||
Scenario: ServerVersionMismatchError carries version attributes
|
||||
Given a ServerVersionMismatchError with client "2.0" and server versions
|
||||
Then the error client_version attribute should be "2.0"
|
||||
And the error server_versions attribute should contain "1.0"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backoff helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: Backoff delay grows exponentially and is capped
|
||||
When I compute backoff delay for attempt 0 with base 1.0 and max 10.0
|
||||
Then the delay should be 1.0
|
||||
When I compute backoff delay for attempt 5 with base 1.0 and max 10.0
|
||||
Then the delay should be 10.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PageResult attributes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: PageResult stores pagination metadata
|
||||
Given a PageResult with 2 items page 1 per_page 10 total 20 has_next true
|
||||
Then the page result page should be 1
|
||||
And the page result per_page should be 10
|
||||
And the page result total should be 20
|
||||
And the page result has_next should be true
|
||||
@@ -0,0 +1,135 @@
|
||||
@phase2 @client @websocket
|
||||
Feature: WebSocket updates client
|
||||
As a developer
|
||||
I want a WebSocket client for real-time plan updates
|
||||
So that I can receive status, progress, and log events from the server
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Construction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: Create WebSocket client with default settings
|
||||
Given a WebSocketClient with default settings
|
||||
Then the ws_url should be "ws://localhost:8080/ws"
|
||||
And the heartbeat interval should be 30.0
|
||||
And the dedup capacity should be 1000
|
||||
|
||||
Scenario: Create WebSocket client with custom settings
|
||||
Given a WebSocketClient with url "wss://example.com/ws" and token "tok_ws1234567890abcdef"
|
||||
Then the ws_url should be "wss://example.com/ws"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Connection lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: Connect raises NotImplementedError until real transport is available
|
||||
Given a WebSocketClient with default settings
|
||||
When I attempt to connect the ws client
|
||||
Then a NotImplementedError should be raised from ws client
|
||||
|
||||
Scenario: Disconnect sets state to disconnected
|
||||
Given a WebSocketClient with default settings
|
||||
And the ws client state is set to connected
|
||||
When I disconnect the ws client
|
||||
Then the ws client should not be connected
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reconnection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: Reconnect increments reconnect counter
|
||||
Given a WebSocketClient with default settings and max_reconnects 5
|
||||
And the ws client state is set to connected
|
||||
When I simulate a reconnect
|
||||
Then the reconnect count should be 1
|
||||
And the ws client should be connected
|
||||
|
||||
Scenario: Reconnect exceeding max raises ServerConnectionError
|
||||
Given a WebSocketClient with default settings and max_reconnects 2
|
||||
When I exhaust reconnect attempts
|
||||
Then a ServerConnectionError should be raised from ws client
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Event processing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: Process event dispatches to subscriber
|
||||
Given a WebSocketClient with a subscriber
|
||||
When I process a plan status event with id "evt-001"
|
||||
Then the subscriber should receive event "evt-001"
|
||||
And the last event id should be "evt-001"
|
||||
|
||||
Scenario: Process duplicate event is skipped
|
||||
Given a WebSocketClient with a subscriber
|
||||
When I process a plan status event with id "evt-dup-001"
|
||||
And I process a plan status event with id "evt-dup-001" again
|
||||
Then the subscriber should have received 1 event
|
||||
|
||||
Scenario: Unsubscribe removes callback
|
||||
Given a WebSocketClient with a subscriber
|
||||
When I unsubscribe the callback
|
||||
And I process a plan status event with id "evt-unsub"
|
||||
Then the subscriber should have received 0 events
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Heartbeat
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: Heartbeat resets reconnect counter
|
||||
Given a WebSocketClient with default settings and max_reconnects 5
|
||||
And the ws client state is set to connected
|
||||
When I simulate a reconnect
|
||||
And I handle a heartbeat
|
||||
Then the reconnect count should be 0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Version negotiation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: Version negotiation stores agreed version
|
||||
Given a WebSocketClient with default settings
|
||||
When I negotiate ws version "1.0"
|
||||
Then the negotiated ws version should be "1.0"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EventDeduplicator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: Deduplicator detects duplicates
|
||||
Given an EventDeduplicator with capacity 3
|
||||
When I check event "a" for duplicate
|
||||
Then the dedup result should be not duplicate
|
||||
When I check event "a" for duplicate
|
||||
Then the dedup result should be duplicate
|
||||
|
||||
Scenario: Deduplicator evicts oldest when full
|
||||
Given an EventDeduplicator with capacity 2
|
||||
When I add events "x" and "y" and "z" to the deduplicator
|
||||
Then event "x" should not be tracked
|
||||
And the deduplicator size should be 2
|
||||
|
||||
Scenario: Deduplicator clear removes all entries
|
||||
Given an EventDeduplicator with capacity 10
|
||||
When I add events "a" and "b" and "c" to the deduplicator
|
||||
And I clear the deduplicator
|
||||
Then the deduplicator size should be 0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ConnectionState
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: ConnectionState reset clears all fields
|
||||
Given a ConnectionState with connected true and last_event_id "ev1"
|
||||
When I reset the connection state
|
||||
Then the connection state should be disconnected
|
||||
And the connection state last_event_id should be empty
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backoff helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: WS backoff delay grows exponentially and is capped
|
||||
When I compute ws backoff delay for attempt 0 base 1.0 max 30.0
|
||||
Then the ws delay should be 1.0
|
||||
When I compute ws backoff delay for attempt 10 base 1.0 max 30.0
|
||||
Then the ws delay should be 30.0
|
||||
@@ -0,0 +1,371 @@
|
||||
"""Step definitions for plan sync and remote execution feature tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.client.http_client import ServerHttpClient
|
||||
from cleveragents.client.sync_client import (
|
||||
ConflictPolicy,
|
||||
ExecutionResult,
|
||||
PlanSyncClient,
|
||||
SyncScope,
|
||||
SyncSummary,
|
||||
)
|
||||
|
||||
|
||||
def _mock_response(
|
||||
status: int = 200,
|
||||
body: dict[str, Any] | list[Any] | None = None,
|
||||
) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
status_code=status,
|
||||
json=body,
|
||||
headers={},
|
||||
request=httpx.Request("GET", "http://mock"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SyncScope
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a SyncScope with all flags enabled")
|
||||
def step_scope_all(context: Context) -> None:
|
||||
context.sync_scope = SyncScope()
|
||||
|
||||
|
||||
@given("a SyncScope with only actions and tools enabled")
|
||||
def step_scope_partial(context: Context) -> None:
|
||||
context.sync_scope = SyncScope(
|
||||
actions=True, skills=False, tools=True, projects=False
|
||||
)
|
||||
|
||||
|
||||
@then('the active types should be "{expected}"')
|
||||
def step_check_active_types(context: Context, expected: str) -> None:
|
||||
result = ",".join(context.sync_scope.active_types())
|
||||
assert result == expected, f"Expected {expected!r}, got {result!r}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PlanSyncClient construction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_sync_client(
|
||||
policy: ConflictPolicy = ConflictPolicy.LOCAL_WINS,
|
||||
) -> tuple[PlanSyncClient, MagicMock]:
|
||||
mock_http = MagicMock(spec=ServerHttpClient)
|
||||
client = PlanSyncClient(mock_http, conflict_policy=policy)
|
||||
return client, mock_http
|
||||
|
||||
|
||||
@given("a PlanSyncClient with a mock HTTP client")
|
||||
def step_sync_client(context: Context) -> None:
|
||||
context.sync_client, context.mock_http = _build_sync_client()
|
||||
context.call_error = None
|
||||
|
||||
|
||||
@given("a PlanSyncClient with server_wins conflict policy")
|
||||
def step_sync_client_server_wins(context: Context) -> None:
|
||||
context.sync_client, context.mock_http = _build_sync_client(
|
||||
ConflictPolicy.SERVER_WINS
|
||||
)
|
||||
context.call_error = None
|
||||
|
||||
|
||||
@given("a PlanSyncClient with local_wins conflict policy")
|
||||
def step_sync_client_local_wins(context: Context) -> None:
|
||||
context.sync_client, context.mock_http = _build_sync_client(
|
||||
ConflictPolicy.LOCAL_WINS
|
||||
)
|
||||
context.call_error = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Item lists
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a list of {n:d} new items without server_id")
|
||||
def step_new_items(context: Context, n: int) -> None:
|
||||
context.sync_items = [{"id": f"item-{i}", "name": f"Item {i}"} for i in range(n)]
|
||||
# Mock POST returns new server_id
|
||||
context.mock_http.request.return_value = _mock_response(200, {"id": "srv-new-001"})
|
||||
|
||||
|
||||
@given("a list of {n:d} existing items with server_id and newer version")
|
||||
def step_existing_newer(context: Context, n: int) -> None:
|
||||
context.sync_items = [
|
||||
{"id": f"item-{i}", "server_id": f"srv-{i}", "version": 2} for i in range(n)
|
||||
]
|
||||
|
||||
# Mock GET returns older version, PUT returns OK
|
||||
def _side_effect(method: str, path: str, **kw: Any) -> httpx.Response:
|
||||
if method == "GET":
|
||||
return _mock_response(200, {"version": 1})
|
||||
return _mock_response(200, {"id": "srv-updated"})
|
||||
|
||||
context.mock_http.request.side_effect = _side_effect
|
||||
|
||||
|
||||
@given("a list of {n:d} existing items with server_id and same version")
|
||||
def step_existing_same(context: Context, n: int) -> None:
|
||||
context.sync_items = [
|
||||
{"id": f"item-{i}", "server_id": f"srv-{i}", "version": 1} for i in range(n)
|
||||
]
|
||||
context.mock_http.request.return_value = _mock_response(200, {"version": 1})
|
||||
|
||||
|
||||
@given("a list with one item missing id")
|
||||
def step_item_missing_id(context: Context) -> None:
|
||||
context.sync_items = [{"name": "no-id"}]
|
||||
|
||||
|
||||
@given("a list of {n:d} existing items where server version is newer")
|
||||
def step_existing_server_newer(context: Context, n: int) -> None:
|
||||
context.sync_items = [
|
||||
{"id": f"item-{i}", "server_id": f"srv-{i}", "version": 1} for i in range(n)
|
||||
]
|
||||
context.mock_http.request.return_value = _mock_response(200, {"version": 5})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sync operations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I sync the items as "{resource_type}"')
|
||||
def step_sync(context: Context, resource_type: str) -> None:
|
||||
context.sync_summary = context.sync_client.sync(context.sync_items, resource_type)
|
||||
|
||||
|
||||
@when('I sync the items as "{resource_type}" with dry_run true')
|
||||
def step_sync_dry(context: Context, resource_type: str) -> None:
|
||||
context.sync_summary = context.sync_client.sync(
|
||||
context.sync_items, resource_type, dry_run=True
|
||||
)
|
||||
|
||||
|
||||
@then("the sync summary should show {created:d} created and {updated:d} updated")
|
||||
def step_check_sync_created_updated(
|
||||
context: Context, created: int, updated: int
|
||||
) -> None:
|
||||
assert context.sync_summary.created == created, (
|
||||
f"Expected {created} created, got {context.sync_summary.created}"
|
||||
)
|
||||
assert context.sync_summary.updated == updated, (
|
||||
f"Expected {updated} updated, got {context.sync_summary.updated}"
|
||||
)
|
||||
|
||||
|
||||
@then("the sync summary should show {c:d} created and {u:d} updated and {s:d} skipped")
|
||||
def step_check_sync_skipped(context: Context, c: int, u: int, s: int) -> None:
|
||||
assert context.sync_summary.created == c
|
||||
assert context.sync_summary.updated == u
|
||||
assert context.sync_summary.skipped == s
|
||||
|
||||
|
||||
@then("the sync summary should show {c:d} created and {u:d} updated and {e:d} error")
|
||||
def step_check_sync_errors(context: Context, c: int, u: int, e: int) -> None:
|
||||
assert context.sync_summary.created == c
|
||||
assert context.sync_summary.updated == u
|
||||
assert context.sync_summary.errors == e
|
||||
|
||||
|
||||
@then("the sync summary should not be a dry run")
|
||||
def step_not_dry_run(context: Context) -> None:
|
||||
assert context.sync_summary.dry_run is False
|
||||
|
||||
|
||||
@then("the sync summary should be a dry run")
|
||||
def step_is_dry_run(context: Context) -> None:
|
||||
assert context.sync_summary.dry_run is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sync all
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("resources for actions and tools")
|
||||
def step_resources(context: Context) -> None:
|
||||
context.resources = {
|
||||
"actions": [{"id": "a1"}],
|
||||
"tools": [{"id": "t1"}],
|
||||
}
|
||||
context.mock_http.request.return_value = _mock_response(200, {"id": "srv-001"})
|
||||
|
||||
|
||||
@when("I sync all with a scope limited to actions only")
|
||||
def step_sync_all_actions(context: Context) -> None:
|
||||
scope = SyncScope(actions=True, skills=False, tools=False, projects=False)
|
||||
context.sync_results = context.sync_client.sync_all(context.resources, scope=scope)
|
||||
|
||||
|
||||
@then("the sync result should contain actions but not tools")
|
||||
def step_check_sync_all(context: Context) -> None:
|
||||
assert "actions" in context.sync_results
|
||||
assert "tools" not in context.sync_results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Remote execution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a PlanSyncClient with a mock HTTP client for execution")
|
||||
def step_sync_client_exec(context: Context) -> None:
|
||||
context.sync_client, context.mock_http = _build_sync_client()
|
||||
context.call_error = None
|
||||
|
||||
def _exec_side_effect(method: str, path: str, **kw: Any) -> httpx.Response:
|
||||
if "execute" in path:
|
||||
return _mock_response(
|
||||
200,
|
||||
{
|
||||
"server_plan_id": "srv-exec-001",
|
||||
"status": "submitted",
|
||||
},
|
||||
)
|
||||
if "apply" in path:
|
||||
return _mock_response(
|
||||
200,
|
||||
{
|
||||
"server_plan_id": "srv-apply-001",
|
||||
"status": "applying",
|
||||
},
|
||||
)
|
||||
return _mock_response(200, {})
|
||||
|
||||
context.mock_http.request.side_effect = _exec_side_effect
|
||||
|
||||
|
||||
@given("a PlanSyncClient with a mock HTTP client for status")
|
||||
def step_sync_client_status(context: Context) -> None:
|
||||
context.sync_client, context.mock_http = _build_sync_client()
|
||||
context.mock_http.request.return_value = _mock_response(
|
||||
200, {"phase": "running", "progress": 50}
|
||||
)
|
||||
context.call_error = None
|
||||
|
||||
|
||||
@when('I execute plan "{plan_id}"')
|
||||
def step_execute_plan(context: Context, plan_id: str) -> None:
|
||||
context.exec_result = context.sync_client.execute_plan(plan_id)
|
||||
|
||||
|
||||
@when('I apply plan "{plan_id}"')
|
||||
def step_apply_plan(context: Context, plan_id: str) -> None:
|
||||
context.exec_result = context.sync_client.apply_plan(plan_id)
|
||||
|
||||
|
||||
@when('I get status for server plan "{server_plan_id}"')
|
||||
def step_get_status(context: Context, server_plan_id: str) -> None:
|
||||
context.plan_status = context.sync_client.get_plan_status(server_plan_id)
|
||||
|
||||
|
||||
@when("I execute plan with empty id")
|
||||
def step_execute_empty(context: Context) -> None:
|
||||
try:
|
||||
context.sync_client.execute_plan("")
|
||||
except ValueError as exc:
|
||||
context.call_error = exc
|
||||
|
||||
|
||||
@when("I apply plan with empty id")
|
||||
def step_apply_empty(context: Context) -> None:
|
||||
try:
|
||||
context.sync_client.apply_plan("")
|
||||
except ValueError as exc:
|
||||
context.call_error = exc
|
||||
|
||||
|
||||
@when("I get status with empty server plan id")
|
||||
def step_status_empty(context: Context) -> None:
|
||||
try:
|
||||
context.sync_client.get_plan_status("")
|
||||
except ValueError as exc:
|
||||
context.call_error = exc
|
||||
|
||||
|
||||
@then('the execution result plan_id should be "{expected}"')
|
||||
def step_check_exec_plan_id(context: Context, expected: str) -> None:
|
||||
assert context.exec_result.plan_id == expected
|
||||
|
||||
|
||||
@then('the execution result status should be "{expected}"')
|
||||
def step_check_exec_status(context: Context, expected: str) -> None:
|
||||
assert context.exec_result.status == expected
|
||||
|
||||
|
||||
@then('the plan status should contain phase "{expected}"')
|
||||
def step_check_plan_phase(context: Context, expected: str) -> None:
|
||||
assert context.plan_status.get("phase") == expected
|
||||
|
||||
|
||||
@then("a ValueError should be raised for empty sync plan_id")
|
||||
def step_value_error_plan_id(context: Context) -> None:
|
||||
assert isinstance(context.call_error, ValueError)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Conflict policy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I change the conflict policy to server_wins")
|
||||
def step_change_policy(context: Context) -> None:
|
||||
context.sync_client.conflict_policy = ConflictPolicy.SERVER_WINS
|
||||
|
||||
|
||||
@then("the conflict policy should be server_wins")
|
||||
def step_check_policy(context: Context) -> None:
|
||||
assert context.sync_client.conflict_policy == ConflictPolicy.SERVER_WINS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SyncSummary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a SyncSummary with {c:d} created {u:d} updated {s:d} skipped {e:d} errors")
|
||||
def step_sync_summary(context: Context, c: int, u: int, s: int, e: int) -> None:
|
||||
context.test_summary = SyncSummary(created=c, updated=u, skipped=s, errors=e)
|
||||
|
||||
|
||||
@then("the total processed should be {expected:d}")
|
||||
def step_check_total(context: Context, expected: int) -> None:
|
||||
assert context.test_summary.total_processed == expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ExecutionResult
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given(
|
||||
'an ExecutionResult with plan_id "{pid}" server_plan_id "{spid}" '
|
||||
'status "{st}" message "{msg}"'
|
||||
)
|
||||
def step_exec_result(context: Context, pid: str, spid: str, st: str, msg: str) -> None:
|
||||
context.exec_result = ExecutionResult(
|
||||
plan_id=pid, server_plan_id=spid, status=st, message=msg
|
||||
)
|
||||
|
||||
|
||||
@then('the execution result message should be "{expected}"')
|
||||
def step_check_exec_msg(context: Context, expected: str) -> None:
|
||||
assert context.exec_result.message == expected
|
||||
|
||||
|
||||
@then('the execution result server_plan_id should be "{expected}"')
|
||||
def step_check_exec_spid(context: Context, expected: str) -> None:
|
||||
assert context.exec_result.server_plan_id == expected
|
||||
@@ -0,0 +1,305 @@
|
||||
"""Step definitions for remote project client feature tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.client.http_client import PageResult, ServerHttpClient
|
||||
from cleveragents.client.remote_project import (
|
||||
RemoteProject,
|
||||
RemoteProjectClient,
|
||||
)
|
||||
from cleveragents.core.exceptions import ResourceNotFoundError
|
||||
|
||||
|
||||
def _mock_page_result(
|
||||
items: list[dict[str, Any]],
|
||||
) -> PageResult:
|
||||
return PageResult(
|
||||
items=items, page=1, per_page=50, total=len(items), has_next=False
|
||||
)
|
||||
|
||||
|
||||
_DEFAULT_PROJECTS = [
|
||||
{"id": "rp-001", "name": "project-alpha", "alias": "", "description": "Alpha"},
|
||||
{"id": "rp-002", "name": "project-beta", "alias": "", "description": "Beta"},
|
||||
]
|
||||
|
||||
_ALIASED_PROJECTS = [
|
||||
{"id": "rp-001", "name": "project-alpha", "alias": "alpha", "description": "Alpha"},
|
||||
{"id": "rp-002", "name": "project-beta", "alias": "beta", "description": "Beta"},
|
||||
]
|
||||
|
||||
|
||||
def _build_remote_client(
|
||||
projects: list[dict[str, Any]] | None = None,
|
||||
cache_ttl: float = 300.0,
|
||||
) -> tuple[RemoteProjectClient, MagicMock]:
|
||||
mock_http = MagicMock(spec=ServerHttpClient)
|
||||
items = projects if projects is not None else _DEFAULT_PROJECTS
|
||||
mock_http.list_endpoint.return_value = _mock_page_result(items)
|
||||
client = RemoteProjectClient(mock_http, cache_ttl=cache_ttl)
|
||||
return client, mock_http
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Project listing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a RemoteProjectClient with mock projects in default namespace")
|
||||
def step_remote_client(context: Context) -> None:
|
||||
context.remote_client, context.mock_http = _build_remote_client()
|
||||
context.call_error = None
|
||||
context.http_call_count = 0
|
||||
|
||||
|
||||
@given("a RemoteProjectClient with mock projects with aliases")
|
||||
def step_remote_client_aliases(context: Context) -> None:
|
||||
context.remote_client, context.mock_http = _build_remote_client(_ALIASED_PROJECTS)
|
||||
context.call_error = None
|
||||
|
||||
|
||||
@when('I list remote projects in namespace "{ns}"')
|
||||
def step_list_projects(context: Context, ns: str) -> None:
|
||||
context.remote_projects = context.remote_client.list_projects(ns)
|
||||
context.http_call_count = context.mock_http.list_endpoint.call_count
|
||||
|
||||
|
||||
@when('I list remote projects in namespace "{ns}" again')
|
||||
def step_list_projects_again(context: Context, ns: str) -> None:
|
||||
context.remote_projects = context.remote_client.list_projects(ns)
|
||||
|
||||
|
||||
@when("I list remote projects with force_refresh")
|
||||
def step_list_force_refresh(context: Context) -> None:
|
||||
context.remote_projects = context.remote_client.list_projects(
|
||||
"default", force_refresh=True
|
||||
)
|
||||
|
||||
|
||||
@then("I should receive {n:d} remote projects")
|
||||
def step_check_project_count(context: Context, n: int) -> None:
|
||||
assert len(context.remote_projects) == n
|
||||
|
||||
|
||||
@then('the first remote project name should be "{expected}"')
|
||||
def step_check_first_name(context: Context, expected: str) -> None:
|
||||
assert context.remote_projects[0].name == expected
|
||||
|
||||
|
||||
@then("the HTTP client should have been called once for project listing")
|
||||
def step_http_called_once(context: Context) -> None:
|
||||
assert context.mock_http.list_endpoint.call_count == 1
|
||||
|
||||
|
||||
@then("the HTTP client should have been called twice for project listing")
|
||||
def step_http_called_twice(context: Context) -> None:
|
||||
assert context.mock_http.list_endpoint.call_count == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Project resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I get remote project "{name}" in namespace "{ns}"')
|
||||
def step_get_project(context: Context, name: str, ns: str) -> None:
|
||||
context.resolved_project = context.remote_client.get_project(name, ns)
|
||||
|
||||
|
||||
@when('I get remote project "{name}" in namespace "{ns}" expecting error')
|
||||
def step_get_project_error(context: Context, name: str, ns: str) -> None:
|
||||
try:
|
||||
context.remote_client.get_project(name, ns)
|
||||
except ResourceNotFoundError as exc:
|
||||
context.call_error = exc
|
||||
|
||||
|
||||
@when("I get remote project with empty name")
|
||||
def step_get_empty(context: Context) -> None:
|
||||
try:
|
||||
context.remote_client.get_project("")
|
||||
except ValueError as exc:
|
||||
context.call_error = exc
|
||||
|
||||
|
||||
@when('I resolve project "{name}" in namespace "{ns}" with fallback')
|
||||
def step_resolve_fallback(context: Context, name: str, ns: str) -> None:
|
||||
# Mock: custom namespace returns empty, default has projects
|
||||
|
||||
def _side_effect(path: str, **kwargs: Any) -> PageResult:
|
||||
ns_param = kwargs.get("params", {}).get("namespace", "default")
|
||||
if ns_param == "custom":
|
||||
return _mock_page_result([])
|
||||
return _mock_page_result(_DEFAULT_PROJECTS)
|
||||
|
||||
context.mock_http.list_endpoint.side_effect = _side_effect
|
||||
context.resolved_project = context.remote_client.resolve_project(name, ns)
|
||||
|
||||
|
||||
@when("I resolve project with empty name")
|
||||
def step_resolve_empty(context: Context) -> None:
|
||||
try:
|
||||
context.remote_client.resolve_project("")
|
||||
except ValueError as exc:
|
||||
context.call_error = exc
|
||||
|
||||
|
||||
@then('the resolved project name should be "{expected}"')
|
||||
def step_check_resolved_name(context: Context, expected: str) -> None:
|
||||
assert context.resolved_project.name == expected
|
||||
|
||||
|
||||
@then('the resolved project namespace should be "{expected}"')
|
||||
def step_check_resolved_ns(context: Context, expected: str) -> None:
|
||||
assert context.resolved_project.namespace == expected
|
||||
|
||||
|
||||
@then("a ResourceNotFoundError should be raised for remote project")
|
||||
def step_not_found_error(context: Context) -> None:
|
||||
assert isinstance(context.call_error, ResourceNotFoundError)
|
||||
|
||||
|
||||
@then("a ValueError should be raised for remote project name")
|
||||
def step_value_error_name(context: Context) -> None:
|
||||
assert isinstance(context.call_error, ValueError)
|
||||
|
||||
|
||||
@then("a ValueError should be raised for remote resolve name")
|
||||
def step_value_error_resolve(context: Context) -> None:
|
||||
assert isinstance(context.call_error, ValueError)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Execution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mock_exec_response(
|
||||
status: int = 200, body: dict[str, Any] | None = None
|
||||
) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
status_code=status,
|
||||
json=body,
|
||||
headers={},
|
||||
request=httpx.Request("POST", "http://mock"),
|
||||
)
|
||||
|
||||
|
||||
@given("a RemoteProjectClient with mock execution endpoint")
|
||||
def step_remote_exec(context: Context) -> None:
|
||||
context.remote_client, context.mock_http = _build_remote_client()
|
||||
context.mock_http.request.return_value = _mock_exec_response(
|
||||
200, {"status": "submitted", "execution_id": "exec-001"}
|
||||
)
|
||||
context.call_error = None
|
||||
|
||||
|
||||
@when('I request execution of remote project "{pid}"')
|
||||
def step_request_exec(context: Context, pid: str) -> None:
|
||||
context.exec_response = context.remote_client.request_execution(pid)
|
||||
|
||||
|
||||
@when('I request remote project "{pid}" execution using plan "{plan}"')
|
||||
def step_request_exec_plan(context: Context, pid: str, plan: str) -> None:
|
||||
context.exec_response = context.remote_client.request_execution(pid, plan_name=plan)
|
||||
|
||||
|
||||
@when("I request execution with empty project_id")
|
||||
def step_request_exec_empty(context: Context) -> None:
|
||||
try:
|
||||
context.remote_client.request_execution("")
|
||||
except ValueError as exc:
|
||||
context.call_error = exc
|
||||
|
||||
|
||||
@then('the execution response should contain status "{expected}"')
|
||||
def step_check_exec_response(context: Context, expected: str) -> None:
|
||||
assert context.exec_response.get("status") == expected
|
||||
|
||||
|
||||
@then("a ValueError should be raised for remote project_id")
|
||||
def step_value_error_pid(context: Context) -> None:
|
||||
assert isinstance(context.call_error, ValueError)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cache management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I invalidate cache for namespace "{ns}"')
|
||||
def step_invalidate_ns(context: Context, ns: str) -> None:
|
||||
context.remote_client.invalidate_cache(ns)
|
||||
|
||||
|
||||
@when("I invalidate all cache")
|
||||
def step_invalidate_all(context: Context) -> None:
|
||||
context.remote_client.invalidate_cache()
|
||||
|
||||
|
||||
@then("the remote project cache size should be {expected:d}")
|
||||
def step_check_cache_size(context: Context, expected: int) -> None:
|
||||
assert context.remote_client.cache_size == expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RemoteProject attributes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given(
|
||||
'a RemoteProject with id "{pid}" name "{name}" namespace "{ns}" '
|
||||
'alias "{alias}" description "{desc}"'
|
||||
)
|
||||
def step_remote_project_attrs(
|
||||
context: Context,
|
||||
pid: str,
|
||||
name: str,
|
||||
ns: str,
|
||||
alias: str,
|
||||
desc: str,
|
||||
) -> None:
|
||||
context.test_project = RemoteProject(
|
||||
project_id=pid,
|
||||
name=name,
|
||||
namespace=ns,
|
||||
alias=alias,
|
||||
description=desc,
|
||||
)
|
||||
|
||||
|
||||
@then('the remote project project_id should be "{expected}"')
|
||||
def step_check_pid(context: Context, expected: str) -> None:
|
||||
assert context.test_project.project_id == expected
|
||||
|
||||
|
||||
@then('the remote project alias should be "{expected}"')
|
||||
def step_check_alias(context: Context, expected: str) -> None:
|
||||
assert context.test_project.alias == expected
|
||||
|
||||
|
||||
@then('the remote project description should be "{expected}"')
|
||||
def step_check_desc(context: Context, expected: str) -> None:
|
||||
assert context.test_project.description == expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cache TTL
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a RemoteProjectClient with cache_ttl {ttl:g}")
|
||||
def step_client_ttl(context: Context, ttl: float) -> None:
|
||||
context.remote_client, _ = _build_remote_client(cache_ttl=ttl)
|
||||
|
||||
|
||||
@then("the client cache_ttl should be {expected:g}")
|
||||
def step_check_ttl(context: Context, expected: float) -> None:
|
||||
assert context.remote_client.cache_ttl == expected
|
||||
@@ -0,0 +1,513 @@
|
||||
"""Step definitions for server HTTP client feature tests.
|
||||
|
||||
Covers construction, health check, version negotiation, pagination,
|
||||
error mapping, retry logic, auth redaction, and the settings factory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.a2a.errors import A2aNotAvailableError
|
||||
from cleveragents.client.exceptions import (
|
||||
ServerConnectionError,
|
||||
ServerTimeoutError,
|
||||
ServerVersionMismatchError,
|
||||
)
|
||||
from cleveragents.client.http_client import (
|
||||
PageResult,
|
||||
ServerHttpClient,
|
||||
_backoff_delay,
|
||||
_redact_headers,
|
||||
create_client_from_settings,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers — mock httpx.request
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mock_response(
|
||||
status: int = 200,
|
||||
body: dict[str, Any] | list[Any] | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> httpx.Response:
|
||||
"""Build a fake httpx.Response."""
|
||||
resp = httpx.Response(
|
||||
status_code=status,
|
||||
json=body,
|
||||
headers=headers or {},
|
||||
request=httpx.Request("GET", "http://mock"),
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Construction and configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a ServerHttpClient with default settings")
|
||||
def step_client_defaults(context: Context) -> None:
|
||||
context.http_client = ServerHttpClient()
|
||||
context.call_error = None
|
||||
|
||||
|
||||
@given('a ServerHttpClient with base_url "{url}" and token "{token}"')
|
||||
def step_client_custom(context: Context, url: str, token: str) -> None:
|
||||
context.http_client = ServerHttpClient(base_url=url, api_token=token)
|
||||
context.call_error = None
|
||||
|
||||
|
||||
@given("a ServerHttpClient with tls_verify disabled")
|
||||
def step_client_no_tls(context: Context) -> None:
|
||||
context.http_client = ServerHttpClient(tls_verify=False)
|
||||
context.call_error = None
|
||||
|
||||
|
||||
@then('the client base_url should be "{expected}"')
|
||||
def step_check_base_url(context: Context, expected: str) -> None:
|
||||
assert context.http_client.base_url == expected
|
||||
|
||||
|
||||
@then("the client tls_verify should be true")
|
||||
def step_check_tls_true(context: Context) -> None:
|
||||
assert context.http_client.tls_verify is True
|
||||
|
||||
|
||||
@then("the client tls_verify should be false")
|
||||
def step_check_tls_false(context: Context) -> None:
|
||||
assert context.http_client.tls_verify is False
|
||||
|
||||
|
||||
@then("the client timeout should be {expected:g}")
|
||||
def step_check_timeout(context: Context, expected: float) -> None:
|
||||
assert context.http_client.timeout == expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Health check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a ServerHttpClient with a mock healthy server")
|
||||
def step_mock_healthy(context: Context) -> None:
|
||||
context.http_client = ServerHttpClient()
|
||||
context._mock_responses = [_mock_response(200, {"status": "healthy"})]
|
||||
context.call_error = None
|
||||
|
||||
|
||||
@given("a ServerHttpClient with an unreachable server")
|
||||
def step_mock_unreachable(context: Context) -> None:
|
||||
context.http_client = ServerHttpClient(base_url="http://192.0.2.1:1", max_retries=1)
|
||||
context._mock_connect_error = True
|
||||
context.call_error = None
|
||||
|
||||
|
||||
@when("I call health_check on the http client")
|
||||
def step_health_check(context: Context) -> None:
|
||||
if getattr(context, "_mock_connect_error", False):
|
||||
with patch("cleveragents.client.http_client.httpx.request") as mock_req:
|
||||
mock_req.side_effect = httpx.ConnectError("refused")
|
||||
context.health_result = context.http_client.health_check()
|
||||
elif hasattr(context, "_mock_responses"):
|
||||
with patch("cleveragents.client.http_client.httpx.request") as mock_req:
|
||||
mock_req.return_value = context._mock_responses[0]
|
||||
context.health_result = context.http_client.health_check()
|
||||
else:
|
||||
context.health_result = context.http_client.health_check()
|
||||
|
||||
|
||||
@then("the health check result should be true")
|
||||
def step_health_true(context: Context) -> None:
|
||||
assert context.health_result is True
|
||||
|
||||
|
||||
@then("the health check result should be false")
|
||||
def step_health_false(context: Context) -> None:
|
||||
assert context.health_result is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Version retrieval
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given('a ServerHttpClient with a mock version server returning "{version}"')
|
||||
def step_mock_version_server(context: Context, version: str) -> None:
|
||||
context.http_client = ServerHttpClient()
|
||||
context._mock_responses = [_mock_response(200, {"version": version})]
|
||||
context.call_error = None
|
||||
|
||||
|
||||
@when("I call get_version on the http client")
|
||||
def step_get_version(context: Context) -> None:
|
||||
with patch("cleveragents.client.http_client.httpx.request") as mock_req:
|
||||
if hasattr(context, "_mock_fail_then_succeed"):
|
||||
mock_req.side_effect = context._mock_fail_then_succeed
|
||||
else:
|
||||
mock_req.return_value = context._mock_responses[0]
|
||||
context.version_result = context.http_client.get_version()
|
||||
|
||||
|
||||
@then('the version result should be "{expected}"')
|
||||
def step_check_version(context: Context, expected: str) -> None:
|
||||
assert context.version_result == expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Version negotiation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given('a ServerHttpClient with a mock negotiate server returning "{version}"')
|
||||
def step_mock_negotiate(context: Context, version: str) -> None:
|
||||
context.http_client = ServerHttpClient()
|
||||
body: dict[str, Any] = {"negotiated_version": version} if version else {}
|
||||
context._mock_responses = [_mock_response(200, body)]
|
||||
context.call_error = None
|
||||
|
||||
|
||||
@when("I call negotiate_version on the http client")
|
||||
def step_negotiate(context: Context) -> None:
|
||||
try:
|
||||
with patch("cleveragents.client.http_client.httpx.request") as mock_req:
|
||||
mock_req.return_value = context._mock_responses[0]
|
||||
context.negotiated_version = context.http_client.negotiate_version()
|
||||
except ServerVersionMismatchError as exc:
|
||||
context.call_error = exc
|
||||
|
||||
|
||||
@then('the http client negotiated version should be "{expected}"')
|
||||
def step_check_negotiated(context: Context, expected: str) -> None:
|
||||
assert context.negotiated_version == expected
|
||||
|
||||
|
||||
@then("a ServerVersionMismatchError should be raised")
|
||||
def step_version_mismatch_raised(context: Context) -> None:
|
||||
assert isinstance(context.call_error, ServerVersionMismatchError)
|
||||
|
||||
|
||||
@given("a ServerHttpClient with a mock negotiate server returning empty")
|
||||
def step_mock_negotiate_empty(context: Context) -> None:
|
||||
context.http_client = ServerHttpClient()
|
||||
context._mock_responses = [_mock_response(200, {})]
|
||||
context.call_error = None
|
||||
|
||||
|
||||
@when("I call negotiate_version on the http client expecting error")
|
||||
def step_negotiate_expecting_error(context: Context) -> None:
|
||||
try:
|
||||
with patch("cleveragents.client.http_client.httpx.request") as mock_req:
|
||||
mock_req.return_value = context._mock_responses[0]
|
||||
context.http_client.negotiate_version()
|
||||
except ServerVersionMismatchError as exc:
|
||||
context.call_error = exc
|
||||
|
||||
|
||||
@then("a ServerVersionMismatchError should be raised from http client")
|
||||
def step_version_mismatch_raised_http(context: Context) -> None:
|
||||
assert isinstance(context.call_error, ServerVersionMismatchError)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pagination
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a ServerHttpClient with a mock paginated endpoint returning {n:d} items")
|
||||
def step_mock_paginated(context: Context, n: int) -> None:
|
||||
context.http_client = ServerHttpClient()
|
||||
items = [{"id": str(i)} for i in range(n)]
|
||||
context._mock_responses = [
|
||||
_mock_response(200, {"items": items, "total": n, "has_next": False})
|
||||
]
|
||||
context.call_error = None
|
||||
|
||||
|
||||
@given("a ServerHttpClient with a mock list endpoint returning {n:d} items")
|
||||
def step_mock_list(context: Context, n: int) -> None:
|
||||
context.http_client = ServerHttpClient()
|
||||
items = [{"id": str(i)} for i in range(n)]
|
||||
context._mock_responses = [_mock_response(200, items)]
|
||||
context.call_error = None
|
||||
|
||||
|
||||
@when("I call list_endpoint with page {page:d} per_page {per_page:d}")
|
||||
def step_list_endpoint(context: Context, page: int, per_page: int) -> None:
|
||||
with patch("cleveragents.client.http_client.httpx.request") as mock_req:
|
||||
mock_req.return_value = context._mock_responses[0]
|
||||
context.page_result = context.http_client.list_endpoint(
|
||||
"/items", page=page, per_page=per_page
|
||||
)
|
||||
|
||||
|
||||
@then("the page result should have {n:d} items")
|
||||
def step_check_page_items(context: Context, n: int) -> None:
|
||||
assert len(context.page_result.items) == n
|
||||
|
||||
|
||||
@then("the page result should have total {n:d}")
|
||||
def step_check_page_total(context: Context, n: int) -> None:
|
||||
assert context.page_result.total == n
|
||||
|
||||
|
||||
@then("the page result has_next should be false")
|
||||
def step_check_page_no_next(context: Context) -> None:
|
||||
assert context.page_result.has_next is False
|
||||
|
||||
|
||||
@then("the page result has_next should be true")
|
||||
def step_check_page_has_next(context: Context) -> None:
|
||||
assert context.page_result.has_next is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a ServerHttpClient with a mock server returning {status:d}")
|
||||
def step_mock_error_server(context: Context, status: int) -> None:
|
||||
context.http_client = ServerHttpClient(max_retries=1)
|
||||
context._mock_responses = [_mock_response(status, {"error": "test"})]
|
||||
context.call_error = None
|
||||
|
||||
|
||||
@when("I call get_version and capture the error")
|
||||
def step_get_version_capture(context: Context) -> None:
|
||||
try:
|
||||
with patch("cleveragents.client.http_client.httpx.request") as mock_req:
|
||||
mock_req.return_value = context._mock_responses[0]
|
||||
context.http_client.get_version()
|
||||
except Exception as exc:
|
||||
context.call_error = exc
|
||||
|
||||
|
||||
@then("an A2aNotAvailableError should be raised from the http client")
|
||||
def step_acp_not_available(context: Context) -> None:
|
||||
assert isinstance(context.call_error, A2aNotAvailableError), (
|
||||
f"Expected A2aNotAvailableError, got {type(context.call_error)}"
|
||||
)
|
||||
|
||||
|
||||
@then("a ServerConnectionError should be raised from the http client")
|
||||
def step_connection_error(context: Context) -> None:
|
||||
assert isinstance(context.call_error, ServerConnectionError), (
|
||||
f"Expected ServerConnectionError, got {type(context.call_error)}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Retry logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a ServerHttpClient with a mock server that fails twice then succeeds")
|
||||
def step_mock_retry(context: Context) -> None:
|
||||
context.http_client = ServerHttpClient(max_retries=3, backoff_base=0.001)
|
||||
call_count = 0
|
||||
|
||||
def _side_effect(*args: Any, **kwargs: Any) -> httpx.Response:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count < 3:
|
||||
return _mock_response(500, {"error": "temp"})
|
||||
return _mock_response(200, {"version": "1.0.0"})
|
||||
|
||||
context._mock_fail_then_succeed = _side_effect
|
||||
context.call_error = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth header redaction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("request headers with an Authorization bearer token")
|
||||
def step_headers_with_auth(context: Context) -> None:
|
||||
context.raw_headers = {
|
||||
"Authorization": "Bearer tok_secret123abcdefghij",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
|
||||
@when("I redact the headers")
|
||||
def step_redact(context: Context) -> None:
|
||||
context.redacted_headers = _redact_headers(context.raw_headers)
|
||||
|
||||
|
||||
@then('the Authorization value should be "***REDACTED***"')
|
||||
def step_auth_redacted(context: Context) -> None:
|
||||
assert context.redacted_headers["Authorization"] == "***REDACTED***"
|
||||
|
||||
|
||||
@then("the Accept header should not be redacted")
|
||||
def step_accept_not_redacted(context: Context) -> None:
|
||||
assert context.redacted_headers["Accept"] == "application/json"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I call close on the http client")
|
||||
def step_close(context: Context) -> None:
|
||||
try:
|
||||
context.http_client.close()
|
||||
context.call_error = None
|
||||
except Exception as exc:
|
||||
context.call_error = exc
|
||||
|
||||
|
||||
@then("no error should be raised from the http client")
|
||||
def step_no_error(context: Context) -> None:
|
||||
assert context.call_error is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I call create_client_from_settings without a configured URL")
|
||||
def step_factory_no_url(context: Context) -> None:
|
||||
try:
|
||||
with patch("cleveragents.config.settings.Settings.get_settings") as mock_gs:
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.server_base_url = None
|
||||
mock_gs.return_value = mock_settings
|
||||
create_client_from_settings()
|
||||
except ValueError as exc:
|
||||
context.call_error = exc
|
||||
|
||||
|
||||
@then("a ValueError should be raised with URL configuration message from factory")
|
||||
def step_value_error_url(context: Context) -> None:
|
||||
assert isinstance(context.call_error, ValueError)
|
||||
assert "server_base_url" in str(context.call_error)
|
||||
|
||||
|
||||
@when("I call create_client_from_settings with a configured URL")
|
||||
def step_factory_with_url(context: Context) -> None:
|
||||
with patch("cleveragents.config.settings.Settings.get_settings") as mock_gs:
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.server_base_url = "https://test.example.com"
|
||||
mock_settings.server_api_token = "tok_testabc123defghijk"
|
||||
mock_settings.server_tls_verify = True
|
||||
mock_settings.server_request_timeout = 15.0
|
||||
mock_gs.return_value = mock_settings
|
||||
context.factory_client = create_client_from_settings()
|
||||
|
||||
|
||||
@then("a ServerHttpClient should be returned")
|
||||
def step_factory_returns_client(context: Context) -> None:
|
||||
assert isinstance(context.factory_client, ServerHttpClient)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exception attributes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given('a ServerConnectionError with url "{url}" and cause')
|
||||
def step_conn_err_attrs(context: Context, url: str) -> None:
|
||||
context.test_error = ServerConnectionError(
|
||||
message="test", url=url, cause=RuntimeError("boom")
|
||||
)
|
||||
|
||||
|
||||
@then('the error url attribute should be "{expected}"')
|
||||
def step_check_err_url(context: Context, expected: str) -> None:
|
||||
assert context.test_error.url == expected
|
||||
|
||||
|
||||
@then("the error cause attribute should not be None")
|
||||
def step_check_err_cause(context: Context) -> None:
|
||||
assert context.test_error.cause is not None
|
||||
|
||||
|
||||
@given("a ServerTimeoutError with {seconds:g} seconds")
|
||||
def step_timeout_err(context: Context, seconds: float) -> None:
|
||||
context.test_error = ServerTimeoutError(message="timeout", timeout_seconds=seconds)
|
||||
|
||||
|
||||
@then("the error timeout_seconds attribute should be {expected:g}")
|
||||
def step_check_timeout_secs(context: Context, expected: float) -> None:
|
||||
assert context.test_error.timeout_seconds == expected
|
||||
|
||||
|
||||
@given('a ServerVersionMismatchError with client "{cv}" and server versions')
|
||||
def step_version_err(context: Context, cv: str) -> None:
|
||||
context.test_error = ServerVersionMismatchError(
|
||||
message="mismatch",
|
||||
client_version=cv,
|
||||
server_versions=["1.0"],
|
||||
)
|
||||
|
||||
|
||||
@then('the error client_version attribute should be "{expected}"')
|
||||
def step_check_client_version(context: Context, expected: str) -> None:
|
||||
assert context.test_error.client_version == expected
|
||||
|
||||
|
||||
@then('the error server_versions attribute should contain "{expected}"')
|
||||
def step_check_server_versions(context: Context, expected: str) -> None:
|
||||
assert expected in context.test_error.server_versions
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backoff helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when(
|
||||
"I compute backoff delay for attempt {attempt:d} with base {base:g} and max {maximum:g}"
|
||||
)
|
||||
def step_compute_backoff(
|
||||
context: Context, attempt: int, base: float, maximum: float
|
||||
) -> None:
|
||||
context.backoff_delay = _backoff_delay(attempt, base, maximum)
|
||||
|
||||
|
||||
@then("the delay should be {expected:g}")
|
||||
def step_check_delay(context: Context, expected: float) -> None:
|
||||
assert context.backoff_delay == expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PageResult attributes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given(
|
||||
"a PageResult with {n:d} items page {page:d} per_page {pp:d} total {total:d} has_next true"
|
||||
)
|
||||
def step_page_result_attrs(
|
||||
context: Context, n: int, page: int, pp: int, total: int
|
||||
) -> None:
|
||||
items = [{"id": str(i)} for i in range(n)]
|
||||
context.page_result = PageResult(
|
||||
items=items, page=page, per_page=pp, total=total, has_next=True
|
||||
)
|
||||
|
||||
|
||||
@then("the page result page should be {expected:d}")
|
||||
def step_check_page_num(context: Context, expected: int) -> None:
|
||||
assert context.page_result.page == expected
|
||||
|
||||
|
||||
@then("the page result per_page should be {expected:d}")
|
||||
def step_check_per_page(context: Context, expected: int) -> None:
|
||||
assert context.page_result.per_page == expected
|
||||
|
||||
|
||||
@then("the page result total should be {expected:d}")
|
||||
def step_check_total(context: Context, expected: int) -> None:
|
||||
assert context.page_result.total == expected
|
||||
@@ -0,0 +1,310 @@
|
||||
"""Step definitions for WebSocket updates client feature tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.a2a.models import A2aEvent
|
||||
from cleveragents.client.exceptions import ServerConnectionError
|
||||
from cleveragents.client.ws_client import (
|
||||
ConnectionState,
|
||||
EventDeduplicator,
|
||||
WebSocketClient,
|
||||
_ws_backoff_delay,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Construction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a WebSocketClient with default settings")
|
||||
def step_ws_default(context: Context) -> None:
|
||||
context.ws_client = WebSocketClient()
|
||||
context.call_error = None
|
||||
|
||||
|
||||
@given('a WebSocketClient with url "{url}" and token "{token}"')
|
||||
def step_ws_custom(context: Context, url: str, token: str) -> None:
|
||||
context.ws_client = WebSocketClient(ws_url=url, api_token=token)
|
||||
context.call_error = None
|
||||
|
||||
|
||||
@given("a WebSocketClient with default settings and max_reconnects {n:d}")
|
||||
def step_ws_max_reconnects(context: Context, n: int) -> None:
|
||||
context.ws_client = WebSocketClient(
|
||||
max_reconnects=n, reconnect_base=0.001, reconnect_max=0.01
|
||||
)
|
||||
context.call_error = None
|
||||
|
||||
|
||||
@then('the ws_url should be "{expected}"')
|
||||
def step_check_ws_url(context: Context, expected: str) -> None:
|
||||
assert context.ws_client.ws_url == expected
|
||||
|
||||
|
||||
@then("the heartbeat interval should be {expected:g}")
|
||||
def step_check_heartbeat(context: Context, expected: float) -> None:
|
||||
assert context.ws_client.heartbeat_interval == expected
|
||||
|
||||
|
||||
@then("the dedup capacity should be {expected:d}")
|
||||
def step_check_dedup_cap(context: Context, expected: int) -> None:
|
||||
assert context.ws_client.dedup_capacity == expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Connection lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I attempt to connect the ws client")
|
||||
def step_ws_attempt_connect(context: Context) -> None:
|
||||
try:
|
||||
context.ws_client.connect()
|
||||
except NotImplementedError as exc:
|
||||
context.call_error = exc
|
||||
|
||||
|
||||
@given("the ws client state is set to connected")
|
||||
def step_ws_set_connected(context: Context) -> None:
|
||||
context.ws_client._state.connected = True
|
||||
context.ws_client._running = True
|
||||
|
||||
|
||||
@when("I disconnect the ws client")
|
||||
def step_ws_disconnect(context: Context) -> None:
|
||||
context.ws_client.disconnect()
|
||||
|
||||
|
||||
@then("the ws client should be connected")
|
||||
def step_ws_connected(context: Context) -> None:
|
||||
assert context.ws_client.connected is True
|
||||
|
||||
|
||||
@then("the ws client should not be connected")
|
||||
def step_ws_not_connected(context: Context) -> None:
|
||||
assert context.ws_client.connected is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reconnection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I simulate a reconnect")
|
||||
def step_ws_reconnect(context: Context) -> None:
|
||||
context.ws_client._state.connected = False
|
||||
context.ws_client.reconnect()
|
||||
|
||||
|
||||
@when("I exhaust reconnect attempts")
|
||||
def step_ws_exhaust_reconnects(context: Context) -> None:
|
||||
context.ws_client._state.connected = True
|
||||
context.ws_client._running = True
|
||||
try:
|
||||
for _ in range(context.ws_client._max_reconnects + 1):
|
||||
context.ws_client._state.connected = False
|
||||
context.ws_client.reconnect()
|
||||
except ServerConnectionError as exc:
|
||||
context.call_error = exc
|
||||
|
||||
|
||||
@then("the reconnect count should be {expected:d}")
|
||||
def step_check_reconnect_count(context: Context, expected: int) -> None:
|
||||
assert context.ws_client.reconnect_count == expected
|
||||
|
||||
|
||||
@then("a ServerConnectionError should be raised from ws client")
|
||||
def step_ws_conn_error(context: Context) -> None:
|
||||
assert isinstance(context.call_error, ServerConnectionError)
|
||||
|
||||
|
||||
@then("a NotImplementedError should be raised from ws client")
|
||||
def step_ws_not_implemented_error(context: Context) -> None:
|
||||
assert isinstance(context.call_error, NotImplementedError)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Event processing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a WebSocketClient with a subscriber")
|
||||
def step_ws_with_subscriber(context: Context) -> None:
|
||||
context.ws_client = WebSocketClient()
|
||||
context.ws_client._state.connected = True
|
||||
context.ws_client._running = True
|
||||
context.received_events: list[A2aEvent] = []
|
||||
|
||||
def _on_event(event: A2aEvent) -> None:
|
||||
context.received_events.append(event)
|
||||
|
||||
context.ws_callback = _on_event
|
||||
context.ws_client.subscribe(_on_event)
|
||||
context.call_error = None
|
||||
|
||||
|
||||
@when('I process a plan status event with id "{event_id}"')
|
||||
def step_process_event(context: Context, event_id: str) -> None:
|
||||
event = A2aEvent(
|
||||
event_id=event_id,
|
||||
event_type="plan.status",
|
||||
plan_id="plan-001",
|
||||
data={"status": "running"},
|
||||
)
|
||||
context.ws_process_result = context.ws_client.process_event(event)
|
||||
|
||||
|
||||
@when('I process a plan status event with id "{event_id}" again')
|
||||
def step_process_event_again(context: Context, event_id: str) -> None:
|
||||
event = A2aEvent(
|
||||
event_id=event_id,
|
||||
event_type="plan.status",
|
||||
plan_id="plan-001",
|
||||
data={"status": "running"},
|
||||
)
|
||||
context.ws_process_result = context.ws_client.process_event(event)
|
||||
|
||||
|
||||
@when("I unsubscribe the callback")
|
||||
def step_unsubscribe(context: Context) -> None:
|
||||
context.ws_client.unsubscribe(context.ws_callback)
|
||||
|
||||
|
||||
@then('the subscriber should receive event "{event_id}"')
|
||||
def step_check_received(context: Context, event_id: str) -> None:
|
||||
assert any(e.event_id == event_id for e in context.received_events)
|
||||
|
||||
|
||||
@then("the subscriber should have received {n:d} event")
|
||||
def step_check_event_count_singular(context: Context, n: int) -> None:
|
||||
assert len(context.received_events) == n
|
||||
|
||||
|
||||
@then("the subscriber should have received {n:d} events")
|
||||
def step_check_event_count_plural(context: Context, n: int) -> None:
|
||||
assert len(context.received_events) == n
|
||||
|
||||
|
||||
@then('the last event id should be "{expected}"')
|
||||
def step_check_last_event_id(context: Context, expected: str) -> None:
|
||||
assert context.ws_client.last_event_id == expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Heartbeat
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I handle a heartbeat")
|
||||
def step_handle_heartbeat(context: Context) -> None:
|
||||
context.ws_client.handle_heartbeat()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Version negotiation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I negotiate ws version "{version}"')
|
||||
def step_negotiate_ws(context: Context, version: str) -> None:
|
||||
context.ws_negotiated = context.ws_client.negotiate_version(version)
|
||||
|
||||
|
||||
@then('the negotiated ws version should be "{expected}"')
|
||||
def step_check_ws_version(context: Context, expected: str) -> None:
|
||||
assert context.ws_negotiated == expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EventDeduplicator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("an EventDeduplicator with capacity {n:d}")
|
||||
def step_dedup(context: Context, n: int) -> None:
|
||||
context.dedup = EventDeduplicator(capacity=n)
|
||||
context.dedup_result = False
|
||||
|
||||
|
||||
@when('I check event "{event_id}" for duplicate')
|
||||
def step_check_dedup(context: Context, event_id: str) -> None:
|
||||
context.dedup_result = context.dedup.is_duplicate(event_id)
|
||||
|
||||
|
||||
@then("the dedup result should be not duplicate")
|
||||
def step_not_dup(context: Context) -> None:
|
||||
assert context.dedup_result is False
|
||||
|
||||
|
||||
@then("the dedup result should be duplicate")
|
||||
def step_is_dup(context: Context) -> None:
|
||||
assert context.dedup_result is True
|
||||
|
||||
|
||||
@when('I add events "{a}" and "{b}" and "{c}" to the deduplicator')
|
||||
def step_add_events(context: Context, a: str, b: str, c: str) -> None:
|
||||
context.dedup.is_duplicate(a)
|
||||
context.dedup.is_duplicate(b)
|
||||
context.dedup.is_duplicate(c)
|
||||
|
||||
|
||||
@then('event "{event_id}" should not be tracked')
|
||||
def step_not_tracked(context: Context, event_id: str) -> None:
|
||||
# If it's not tracked, calling is_duplicate should return False
|
||||
# (it will also add it, but that's fine for the test)
|
||||
assert event_id not in context.dedup._seen
|
||||
|
||||
|
||||
@then("the deduplicator size should be {expected:d}")
|
||||
def step_dedup_size(context: Context, expected: int) -> None:
|
||||
assert context.dedup.size == expected
|
||||
|
||||
|
||||
@when("I clear the deduplicator")
|
||||
def step_clear_dedup(context: Context) -> None:
|
||||
context.dedup.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ConnectionState
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given('a ConnectionState with connected true and last_event_id "{eid}"')
|
||||
def step_conn_state(context: Context, eid: str) -> None:
|
||||
context.conn_state = ConnectionState()
|
||||
context.conn_state.connected = True
|
||||
context.conn_state.last_event_id = eid
|
||||
|
||||
|
||||
@when("I reset the connection state")
|
||||
def step_reset_state(context: Context) -> None:
|
||||
context.conn_state.reset()
|
||||
|
||||
|
||||
@then("the connection state should be disconnected")
|
||||
def step_state_disconnected(context: Context) -> None:
|
||||
assert context.conn_state.connected is False
|
||||
|
||||
|
||||
@then("the connection state last_event_id should be empty")
|
||||
def step_state_no_event_id(context: Context) -> None:
|
||||
assert context.conn_state.last_event_id == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backoff
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I compute ws backoff delay for attempt {a:d} base {b:g} max {m:g}")
|
||||
def step_ws_backoff(context: Context, a: int, b: float, m: float) -> None:
|
||||
context.ws_delay = _ws_backoff_delay(a, b, m)
|
||||
|
||||
|
||||
@then("the ws delay should be {expected:g}")
|
||||
def step_check_ws_delay(context: Context, expected: float) -> None:
|
||||
assert context.ws_delay == expected
|
||||
@@ -47,6 +47,8 @@ dependencies = [
|
||||
"jsonschema>=4.20.0", # JSON Schema validation for tool inputs/outputs
|
||||
"tomlkit>=0.13.0", # TOML writing with comment preservation for config CLI
|
||||
"tenacity>=8.2.0", # Retry framework for service layer resilience
|
||||
"httpx>=0.27.0", # HTTP client for server communication
|
||||
"websockets>=12.0", # WebSocket client for real-time updates
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Helper script for plan_sync.robot integration tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
from cleveragents.client.http_client import ServerHttpClient # noqa: E402
|
||||
from cleveragents.client.sync_client import ( # noqa: E402
|
||||
ConflictPolicy,
|
||||
ExecutionResult,
|
||||
PlanSyncClient,
|
||||
SyncScope,
|
||||
SyncSummary,
|
||||
)
|
||||
|
||||
|
||||
def _mock_response(
|
||||
status: int = 200, body: dict | list | None = None
|
||||
) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
status_code=status,
|
||||
json=body,
|
||||
headers={},
|
||||
request=httpx.Request("GET", "http://mock"),
|
||||
)
|
||||
|
||||
|
||||
def scope_active() -> None:
|
||||
scope = SyncScope(actions=True, skills=False, tools=True, projects=False)
|
||||
types = scope.active_types()
|
||||
assert types == ["actions", "tools"], f"Got {types}"
|
||||
print("plan-sync-scope-ok")
|
||||
|
||||
|
||||
def sync_create() -> None:
|
||||
mock_http = MagicMock(spec=ServerHttpClient)
|
||||
mock_http.request.return_value = _mock_response(200, {"id": "srv-new"})
|
||||
client = PlanSyncClient(mock_http)
|
||||
items = [{"id": "a1"}, {"id": "a2"}]
|
||||
summary = client.sync(items, "actions")
|
||||
assert summary.created == 2
|
||||
print("plan-sync-create-ok")
|
||||
|
||||
|
||||
def sync_dry_run() -> None:
|
||||
mock_http = MagicMock(spec=ServerHttpClient)
|
||||
client = PlanSyncClient(mock_http)
|
||||
items = [{"id": "a1"}]
|
||||
summary = client.sync(items, "actions", dry_run=True)
|
||||
assert summary.dry_run is True
|
||||
assert summary.created == 1
|
||||
print("plan-sync-dry-run-ok")
|
||||
|
||||
|
||||
def execute_plan() -> None:
|
||||
mock_http = MagicMock(spec=ServerHttpClient)
|
||||
mock_http.request.return_value = _mock_response(
|
||||
200, {"server_plan_id": "srv-001", "status": "submitted"}
|
||||
)
|
||||
client = PlanSyncClient(mock_http)
|
||||
result = client.execute_plan("plan-001")
|
||||
assert result.plan_id == "plan-001"
|
||||
assert result.status == "submitted"
|
||||
print("plan-sync-execute-ok")
|
||||
|
||||
|
||||
def apply_plan() -> None:
|
||||
mock_http = MagicMock(spec=ServerHttpClient)
|
||||
mock_http.request.return_value = _mock_response(
|
||||
200, {"server_plan_id": "srv-002", "status": "applying"}
|
||||
)
|
||||
client = PlanSyncClient(mock_http)
|
||||
result = client.apply_plan("plan-002")
|
||||
assert result.status == "applying"
|
||||
print("plan-sync-apply-ok")
|
||||
|
||||
|
||||
def plan_status() -> None:
|
||||
mock_http = MagicMock(spec=ServerHttpClient)
|
||||
mock_http.request.return_value = _mock_response(
|
||||
200, {"phase": "running", "progress": 50}
|
||||
)
|
||||
client = PlanSyncClient(mock_http)
|
||||
status = client.get_plan_status("srv-001")
|
||||
assert status["phase"] == "running"
|
||||
print("plan-sync-status-ok")
|
||||
|
||||
|
||||
def conflict_policy() -> None:
|
||||
mock_http = MagicMock(spec=ServerHttpClient)
|
||||
client = PlanSyncClient(mock_http, conflict_policy=ConflictPolicy.LOCAL_WINS)
|
||||
assert client.conflict_policy == ConflictPolicy.LOCAL_WINS
|
||||
client.conflict_policy = ConflictPolicy.SERVER_WINS
|
||||
assert client.conflict_policy == ConflictPolicy.SERVER_WINS
|
||||
print("plan-sync-conflict-policy-ok")
|
||||
|
||||
|
||||
def summary_total() -> None:
|
||||
s = SyncSummary(created=2, updated=1, skipped=1, errors=1)
|
||||
assert s.total_processed == 5
|
||||
print("plan-sync-summary-total-ok")
|
||||
|
||||
|
||||
def exec_result_attrs() -> None:
|
||||
r = ExecutionResult(plan_id="p1", server_plan_id="sp1", status="done", message="ok")
|
||||
assert r.message == "ok"
|
||||
assert r.server_plan_id == "sp1"
|
||||
print("plan-sync-exec-result-ok")
|
||||
|
||||
|
||||
_COMMANDS = {
|
||||
"scope-active": scope_active,
|
||||
"sync-create": sync_create,
|
||||
"sync-dry-run": sync_dry_run,
|
||||
"execute-plan": execute_plan,
|
||||
"apply-plan": apply_plan,
|
||||
"plan-status": plan_status,
|
||||
"conflict-policy": conflict_policy,
|
||||
"summary-total": summary_total,
|
||||
"exec-result-attrs": exec_result_attrs,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS:
|
||||
print(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
_COMMANDS[sys.argv[1]]()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Helper script for remote_project.robot integration tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
import httpx # noqa: E402
|
||||
|
||||
from cleveragents.client.http_client import ( # noqa: E402
|
||||
PageResult,
|
||||
ServerHttpClient,
|
||||
)
|
||||
from cleveragents.client.remote_project import ( # noqa: E402
|
||||
RemoteProject,
|
||||
RemoteProjectClient,
|
||||
)
|
||||
from cleveragents.core.exceptions import ResourceNotFoundError # noqa: E402
|
||||
|
||||
_ITEMS = [
|
||||
{"id": "rp1", "name": "alpha", "alias": "a", "description": "A"},
|
||||
{"id": "rp2", "name": "beta", "alias": "b", "description": "B"},
|
||||
]
|
||||
|
||||
|
||||
def _build() -> tuple[RemoteProjectClient, MagicMock]:
|
||||
mock = MagicMock(spec=ServerHttpClient)
|
||||
mock.list_endpoint.return_value = PageResult(
|
||||
items=_ITEMS, page=1, per_page=50, total=2, has_next=False
|
||||
)
|
||||
return RemoteProjectClient(mock), mock
|
||||
|
||||
|
||||
def list_projects() -> None:
|
||||
client, _ = _build()
|
||||
projects = client.list_projects()
|
||||
assert len(projects) == 2
|
||||
assert projects[0].name == "alpha"
|
||||
print("remote-project-list-ok")
|
||||
|
||||
|
||||
def get_project() -> None:
|
||||
client, _ = _build()
|
||||
p = client.get_project("alpha")
|
||||
assert p.name == "alpha"
|
||||
print("remote-project-get-ok")
|
||||
|
||||
|
||||
def get_by_alias() -> None:
|
||||
client, _ = _build()
|
||||
p = client.get_project("a")
|
||||
assert p.name == "alpha"
|
||||
print("remote-project-alias-ok")
|
||||
|
||||
|
||||
def not_found() -> None:
|
||||
client, _ = _build()
|
||||
try:
|
||||
client.get_project("nope")
|
||||
print("FAIL: should have raised", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except ResourceNotFoundError:
|
||||
pass
|
||||
print("remote-project-not-found-ok")
|
||||
|
||||
|
||||
def request_execution() -> None:
|
||||
client, mock = _build()
|
||||
mock.request.return_value = httpx.Response(
|
||||
status_code=200,
|
||||
json={"status": "submitted"},
|
||||
headers={},
|
||||
request=httpx.Request("POST", "http://mock"),
|
||||
)
|
||||
result = client.request_execution("rp1")
|
||||
assert result["status"] == "submitted"
|
||||
print("remote-project-exec-ok")
|
||||
|
||||
|
||||
def cache_invalidate() -> None:
|
||||
client, _ = _build()
|
||||
client.list_projects()
|
||||
assert client.cache_size == 1
|
||||
client.invalidate_cache()
|
||||
assert client.cache_size == 0
|
||||
print("remote-project-cache-ok")
|
||||
|
||||
|
||||
def project_attrs() -> None:
|
||||
p = RemoteProject(
|
||||
project_id="rp1",
|
||||
name="test",
|
||||
namespace="ns",
|
||||
alias="t",
|
||||
description="desc",
|
||||
)
|
||||
assert p.project_id == "rp1"
|
||||
assert p.alias == "t"
|
||||
assert p.description == "desc"
|
||||
print("remote-project-attrs-ok")
|
||||
|
||||
|
||||
_COMMANDS = {
|
||||
"list-projects": list_projects,
|
||||
"get-project": get_project,
|
||||
"get-by-alias": get_by_alias,
|
||||
"not-found": not_found,
|
||||
"request-execution": request_execution,
|
||||
"cache-invalidate": cache_invalidate,
|
||||
"project-attrs": project_attrs,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS:
|
||||
cmds = "|".join(_COMMANDS)
|
||||
print(f"Usage: {sys.argv[0]} <{cmds}>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
_COMMANDS[sys.argv[1]]()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Helper script for server_http_client.robot integration tests.
|
||||
|
||||
Each subcommand is a self-contained check that prints a sentinel on success.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
|
||||
# Ensure local source tree is importable
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
from cleveragents.client.exceptions import ( # noqa: E402
|
||||
ServerConnectionError,
|
||||
ServerTimeoutError,
|
||||
ServerVersionMismatchError,
|
||||
)
|
||||
from cleveragents.client.http_client import ( # noqa: E402
|
||||
ServerHttpClient,
|
||||
_redact_headers,
|
||||
)
|
||||
|
||||
|
||||
def _mock_response(
|
||||
status: int = 200,
|
||||
body: dict | list | None = None,
|
||||
headers: dict | None = None,
|
||||
) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
status_code=status,
|
||||
json=body,
|
||||
headers=headers or {},
|
||||
request=httpx.Request("GET", "http://mock"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subcommands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def client_create() -> None:
|
||||
"""Verify client construction and properties."""
|
||||
client = ServerHttpClient(
|
||||
base_url="https://test.example.com",
|
||||
api_token="tok_testkey1234567890abc",
|
||||
tls_verify=True,
|
||||
timeout=15.0,
|
||||
)
|
||||
assert client.base_url == "https://test.example.com"
|
||||
assert client.tls_verify is True
|
||||
assert client.timeout == 15.0
|
||||
client.close()
|
||||
print("server-http-client-create-ok")
|
||||
|
||||
|
||||
def health_check() -> None:
|
||||
"""Verify health check with mock."""
|
||||
client = ServerHttpClient()
|
||||
with patch("cleveragents.client.http_client.httpx.request") as mock_req:
|
||||
mock_req.return_value = _mock_response(200, {"status": "healthy"})
|
||||
result = client.health_check()
|
||||
assert result is True
|
||||
print("server-http-client-health-ok")
|
||||
|
||||
|
||||
def health_check_fail() -> None:
|
||||
"""Verify health check failure."""
|
||||
client = ServerHttpClient(max_retries=1)
|
||||
with patch("cleveragents.client.http_client.httpx.request") as mock_req:
|
||||
mock_req.side_effect = httpx.ConnectError("refused")
|
||||
result = client.health_check()
|
||||
assert result is False
|
||||
print("server-http-client-health-fail-ok")
|
||||
|
||||
|
||||
def version() -> None:
|
||||
"""Verify version retrieval."""
|
||||
client = ServerHttpClient()
|
||||
with patch("cleveragents.client.http_client.httpx.request") as mock_req:
|
||||
mock_req.return_value = _mock_response(200, {"version": "1.2.3"})
|
||||
result = client.get_version()
|
||||
assert result == "1.2.3"
|
||||
print("server-http-client-version-ok")
|
||||
|
||||
|
||||
def negotiate() -> None:
|
||||
"""Verify version negotiation."""
|
||||
client = ServerHttpClient()
|
||||
with patch("cleveragents.client.http_client.httpx.request") as mock_req:
|
||||
mock_req.return_value = _mock_response(200, {"negotiated_version": "1.0"})
|
||||
result = client.negotiate_version()
|
||||
assert result == "1.0"
|
||||
print("server-http-client-negotiate-ok")
|
||||
|
||||
|
||||
def pagination() -> None:
|
||||
"""Verify paginated list endpoint."""
|
||||
client = ServerHttpClient()
|
||||
items = [{"id": str(i)} for i in range(3)]
|
||||
with patch("cleveragents.client.http_client.httpx.request") as mock_req:
|
||||
mock_req.return_value = _mock_response(
|
||||
200, {"items": items, "total": 3, "has_next": False}
|
||||
)
|
||||
result = client.list_endpoint("/items", page=1, per_page=10)
|
||||
assert len(result.items) == 3
|
||||
assert result.total == 3
|
||||
print("server-http-client-pagination-ok")
|
||||
|
||||
|
||||
def error_503() -> None:
|
||||
"""Verify 503 maps to A2aNotAvailableError."""
|
||||
from cleveragents.a2a.errors import A2aNotAvailableError
|
||||
|
||||
client = ServerHttpClient(max_retries=1)
|
||||
with patch("cleveragents.client.http_client.httpx.request") as mock_req:
|
||||
mock_req.return_value = _mock_response(503, {"error": "unavailable"})
|
||||
try:
|
||||
client.get_version()
|
||||
print("FAIL: should have raised", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except A2aNotAvailableError:
|
||||
pass
|
||||
print("server-http-client-error-503-ok")
|
||||
|
||||
|
||||
def redaction() -> None:
|
||||
"""Verify auth header redaction."""
|
||||
headers = {
|
||||
"Authorization": "Bearer tok_secret123abcdefghij",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
redacted = _redact_headers(headers)
|
||||
assert redacted["Authorization"] == "***REDACTED***"
|
||||
assert redacted["Accept"] == "application/json"
|
||||
print("server-http-client-redaction-ok")
|
||||
|
||||
|
||||
def exceptions() -> None:
|
||||
"""Verify exception attributes."""
|
||||
err1 = ServerConnectionError("test", url="https://x.com", cause=RuntimeError("x"))
|
||||
assert err1.url == "https://x.com"
|
||||
assert err1.cause is not None
|
||||
|
||||
err2 = ServerTimeoutError("timeout", timeout_seconds=5.0)
|
||||
assert err2.timeout_seconds == 5.0
|
||||
|
||||
err3 = ServerVersionMismatchError(
|
||||
"mm", client_version="2.0", server_versions=["1.0"]
|
||||
)
|
||||
assert err3.client_version == "2.0"
|
||||
assert "1.0" in err3.server_versions
|
||||
|
||||
print("server-http-client-exceptions-ok")
|
||||
|
||||
|
||||
_COMMANDS = {
|
||||
"client-create": client_create,
|
||||
"health-check": health_check,
|
||||
"health-check-fail": health_check_fail,
|
||||
"version": version,
|
||||
"negotiate": negotiate,
|
||||
"pagination": pagination,
|
||||
"error-503": error_503,
|
||||
"redaction": redaction,
|
||||
"exceptions": exceptions,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS:
|
||||
print(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
_COMMANDS[sys.argv[1]]()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Helper script for websocket_updates.robot integration tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
from cleveragents.a2a.models import A2aEvent # noqa: E402
|
||||
from cleveragents.client.exceptions import ServerConnectionError # noqa: E402
|
||||
from cleveragents.client.ws_client import ( # noqa: E402
|
||||
ConnectionState,
|
||||
EventDeduplicator,
|
||||
WebSocketClient,
|
||||
)
|
||||
|
||||
|
||||
def connect_raises() -> None:
|
||||
"""Verify connect raises NotImplementedError."""
|
||||
client = WebSocketClient()
|
||||
assert client.connected is False
|
||||
try:
|
||||
client.connect()
|
||||
print("FAIL: should have raised NotImplementedError", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except NotImplementedError:
|
||||
pass
|
||||
# Verify disconnect works when state is set externally
|
||||
client._state.connected = True
|
||||
client._running = True
|
||||
client.disconnect()
|
||||
assert client.connected is False
|
||||
print("ws-connect-raises-ok")
|
||||
|
||||
|
||||
def subscribe_event() -> None:
|
||||
"""Verify event subscription and dispatch."""
|
||||
client = WebSocketClient()
|
||||
client._state.connected = True
|
||||
client._running = True
|
||||
received: list[A2aEvent] = []
|
||||
client.subscribe(received.append)
|
||||
event = A2aEvent(
|
||||
event_id="evt-sub-1",
|
||||
event_type="plan.status",
|
||||
data={"status": "running"},
|
||||
)
|
||||
dispatched = client.process_event(event)
|
||||
assert dispatched is True
|
||||
assert len(received) == 1
|
||||
assert received[0].event_id == "evt-sub-1"
|
||||
print("ws-subscribe-event-ok")
|
||||
|
||||
|
||||
def dedup_event() -> None:
|
||||
"""Verify duplicate event detection."""
|
||||
client = WebSocketClient()
|
||||
client._state.connected = True
|
||||
client._running = True
|
||||
received: list[A2aEvent] = []
|
||||
client.subscribe(received.append)
|
||||
event = A2aEvent(
|
||||
event_id="evt-dup-robot",
|
||||
event_type="plan.status",
|
||||
data={},
|
||||
)
|
||||
client.process_event(event)
|
||||
dup = client.process_event(event)
|
||||
assert dup is False
|
||||
assert len(received) == 1
|
||||
print("ws-dedup-event-ok")
|
||||
|
||||
|
||||
def reconnect() -> None:
|
||||
"""Verify reconnection with backoff."""
|
||||
client = WebSocketClient(max_reconnects=3, reconnect_base=0.001, reconnect_max=0.01)
|
||||
client._state.connected = True
|
||||
client._running = True
|
||||
client._state.connected = False
|
||||
result = client.reconnect()
|
||||
assert result is True
|
||||
assert client.reconnect_count == 1
|
||||
print("ws-reconnect-ok")
|
||||
|
||||
|
||||
def reconnect_exhaust() -> None:
|
||||
"""Verify exhausted reconnects raise error."""
|
||||
client = WebSocketClient(max_reconnects=1, reconnect_base=0.001, reconnect_max=0.01)
|
||||
client._state.connected = True
|
||||
client._running = True
|
||||
client._state.connected = False
|
||||
client.reconnect()
|
||||
client._state.connected = False
|
||||
try:
|
||||
client.reconnect()
|
||||
print("FAIL: should have raised", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except ServerConnectionError:
|
||||
pass
|
||||
print("ws-reconnect-exhaust-ok")
|
||||
|
||||
|
||||
def heartbeat() -> None:
|
||||
"""Verify heartbeat resets reconnect counter."""
|
||||
client = WebSocketClient(max_reconnects=5, reconnect_base=0.001, reconnect_max=0.01)
|
||||
client._state.connected = True
|
||||
client._running = True
|
||||
client._state.connected = False
|
||||
client.reconnect()
|
||||
assert client.reconnect_count == 1
|
||||
client.handle_heartbeat()
|
||||
assert client.reconnect_count == 0
|
||||
print("ws-heartbeat-ok")
|
||||
|
||||
|
||||
def deduplicator() -> None:
|
||||
"""Verify EventDeduplicator behavior."""
|
||||
d = EventDeduplicator(capacity=2)
|
||||
assert d.is_duplicate("a") is False
|
||||
assert d.is_duplicate("b") is False
|
||||
assert d.is_duplicate("c") is False # evicts "a"
|
||||
assert "a" not in d._seen
|
||||
assert d.size == 2
|
||||
d.clear()
|
||||
assert d.size == 0
|
||||
print("ws-deduplicator-ok")
|
||||
|
||||
|
||||
def connection_state() -> None:
|
||||
"""Verify ConnectionState reset."""
|
||||
s = ConnectionState()
|
||||
s.connected = True
|
||||
s.last_event_id = "ev1"
|
||||
s.reset()
|
||||
assert s.connected is False
|
||||
assert s.last_event_id == ""
|
||||
print("ws-connection-state-ok")
|
||||
|
||||
|
||||
def version_negotiate() -> None:
|
||||
"""Verify version negotiation."""
|
||||
client = WebSocketClient()
|
||||
v = client.negotiate_version("1.0")
|
||||
assert v == "1.0"
|
||||
assert client.state.negotiated_version == "1.0"
|
||||
print("ws-version-negotiate-ok")
|
||||
|
||||
|
||||
_COMMANDS = {
|
||||
"connect-raises": connect_raises,
|
||||
"subscribe-event": subscribe_event,
|
||||
"dedup-event": dedup_event,
|
||||
"reconnect": reconnect,
|
||||
"reconnect-exhaust": reconnect_exhaust,
|
||||
"heartbeat": heartbeat,
|
||||
"deduplicator": deduplicator,
|
||||
"connection-state": connection_state,
|
||||
"version-negotiate": version_negotiate,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS:
|
||||
cmds = "|".join(_COMMANDS)
|
||||
print(f"Usage: {sys.argv[0]} <{cmds}>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
_COMMANDS[sys.argv[1]]()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,81 @@
|
||||
*** Settings ***
|
||||
Documentation Integration tests for Plan Sync client
|
||||
Resource ${CURDIR}/common.resource
|
||||
Suite Setup Setup Test Environment
|
||||
Suite Teardown Cleanup Test Environment
|
||||
|
||||
*** Variables ***
|
||||
${HELPER} ${CURDIR}/helper_plan_sync.py
|
||||
|
||||
*** Test Cases ***
|
||||
Plan Sync Scope Active Types
|
||||
[Documentation] Verify SyncScope active_types
|
||||
${result}= Run Process ${PYTHON} ${HELPER} scope-active cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} plan-sync-scope-ok
|
||||
|
||||
Plan Sync Create Items
|
||||
[Documentation] Verify sync creates new items
|
||||
${result}= Run Process ${PYTHON} ${HELPER} sync-create cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} plan-sync-create-ok
|
||||
|
||||
Plan Sync Dry Run
|
||||
[Documentation] Verify dry run mode
|
||||
${result}= Run Process ${PYTHON} ${HELPER} sync-dry-run cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} plan-sync-dry-run-ok
|
||||
|
||||
Plan Sync Execute Plan
|
||||
[Documentation] Verify remote plan execution
|
||||
${result}= Run Process ${PYTHON} ${HELPER} execute-plan cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} plan-sync-execute-ok
|
||||
|
||||
Plan Sync Apply Plan
|
||||
[Documentation] Verify remote plan apply
|
||||
${result}= Run Process ${PYTHON} ${HELPER} apply-plan cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} plan-sync-apply-ok
|
||||
|
||||
Plan Sync Get Status
|
||||
[Documentation] Verify plan status retrieval
|
||||
${result}= Run Process ${PYTHON} ${HELPER} plan-status cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} plan-sync-status-ok
|
||||
|
||||
Plan Sync Conflict Policy
|
||||
[Documentation] Verify conflict policy change
|
||||
${result}= Run Process ${PYTHON} ${HELPER} conflict-policy cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} plan-sync-conflict-policy-ok
|
||||
|
||||
Plan Sync Summary Total
|
||||
[Documentation] Verify SyncSummary total_processed
|
||||
${result}= Run Process ${PYTHON} ${HELPER} summary-total cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} plan-sync-summary-total-ok
|
||||
|
||||
Plan Sync Execution Result Attributes
|
||||
[Documentation] Verify ExecutionResult attributes
|
||||
${result}= Run Process ${PYTHON} ${HELPER} exec-result-attrs cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} plan-sync-exec-result-ok
|
||||
@@ -0,0 +1,65 @@
|
||||
*** Settings ***
|
||||
Documentation Integration tests for Remote Project client
|
||||
Resource ${CURDIR}/common.resource
|
||||
Suite Setup Setup Test Environment
|
||||
Suite Teardown Cleanup Test Environment
|
||||
|
||||
*** Variables ***
|
||||
${HELPER} ${CURDIR}/helper_remote_project.py
|
||||
|
||||
*** Test Cases ***
|
||||
Remote Project List
|
||||
[Documentation] Verify listing remote projects
|
||||
${result}= Run Process ${PYTHON} ${HELPER} list-projects cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} remote-project-list-ok
|
||||
|
||||
Remote Project Get By Name
|
||||
[Documentation] Verify getting project by name
|
||||
${result}= Run Process ${PYTHON} ${HELPER} get-project cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} remote-project-get-ok
|
||||
|
||||
Remote Project Get By Alias
|
||||
[Documentation] Verify getting project by alias
|
||||
${result}= Run Process ${PYTHON} ${HELPER} get-by-alias cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} remote-project-alias-ok
|
||||
|
||||
Remote Project Not Found
|
||||
[Documentation] Verify ResourceNotFoundError for missing project
|
||||
${result}= Run Process ${PYTHON} ${HELPER} not-found cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} remote-project-not-found-ok
|
||||
|
||||
Remote Project Request Execution
|
||||
[Documentation] Verify requesting execution of remote project
|
||||
${result}= Run Process ${PYTHON} ${HELPER} request-execution cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} remote-project-exec-ok
|
||||
|
||||
Remote Project Cache Invalidate
|
||||
[Documentation] Verify cache invalidation
|
||||
${result}= Run Process ${PYTHON} ${HELPER} cache-invalidate cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} remote-project-cache-ok
|
||||
|
||||
Remote Project Attributes
|
||||
[Documentation] Verify RemoteProject attributes
|
||||
${result}= Run Process ${PYTHON} ${HELPER} project-attrs cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} remote-project-attrs-ok
|
||||
@@ -0,0 +1,81 @@
|
||||
*** Settings ***
|
||||
Documentation Integration tests for Server HTTP client
|
||||
Resource ${CURDIR}/common.resource
|
||||
Suite Setup Setup Test Environment
|
||||
Suite Teardown Cleanup Test Environment
|
||||
|
||||
*** Variables ***
|
||||
${HELPER} ${CURDIR}/helper_server_http_client.py
|
||||
|
||||
*** Test Cases ***
|
||||
Server HTTP Client Create
|
||||
[Documentation] Verify client construction and property access
|
||||
${result}= Run Process ${PYTHON} ${HELPER} client-create cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} server-http-client-create-ok
|
||||
|
||||
Server HTTP Client Health Check
|
||||
[Documentation] Verify health check with mock healthy server
|
||||
${result}= Run Process ${PYTHON} ${HELPER} health-check cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} server-http-client-health-ok
|
||||
|
||||
Server HTTP Client Health Check Failure
|
||||
[Documentation] Verify health check returns false for unreachable server
|
||||
${result}= Run Process ${PYTHON} ${HELPER} health-check-fail cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} server-http-client-health-fail-ok
|
||||
|
||||
Server HTTP Client Version
|
||||
[Documentation] Verify version retrieval
|
||||
${result}= Run Process ${PYTHON} ${HELPER} version cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} server-http-client-version-ok
|
||||
|
||||
Server HTTP Client Negotiate
|
||||
[Documentation] Verify version negotiation
|
||||
${result}= Run Process ${PYTHON} ${HELPER} negotiate cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} server-http-client-negotiate-ok
|
||||
|
||||
Server HTTP Client Pagination
|
||||
[Documentation] Verify paginated list endpoint
|
||||
${result}= Run Process ${PYTHON} ${HELPER} pagination cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} server-http-client-pagination-ok
|
||||
|
||||
Server HTTP Client Error 503
|
||||
[Documentation] Verify 503 maps to AcpNotAvailableError
|
||||
${result}= Run Process ${PYTHON} ${HELPER} error-503 cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} server-http-client-error-503-ok
|
||||
|
||||
Server HTTP Client Auth Redaction
|
||||
[Documentation] Verify auth header redaction
|
||||
${result}= Run Process ${PYTHON} ${HELPER} redaction cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} server-http-client-redaction-ok
|
||||
|
||||
Server HTTP Client Exceptions
|
||||
[Documentation] Verify client exception attributes
|
||||
${result}= Run Process ${PYTHON} ${HELPER} exceptions cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} server-http-client-exceptions-ok
|
||||
@@ -0,0 +1,81 @@
|
||||
*** Settings ***
|
||||
Documentation Integration tests for WebSocket updates client
|
||||
Resource ${CURDIR}/common.resource
|
||||
Suite Setup Setup Test Environment
|
||||
Suite Teardown Cleanup Test Environment
|
||||
|
||||
*** Variables ***
|
||||
${HELPER} ${CURDIR}/helper_websocket_updates.py
|
||||
|
||||
*** Test Cases ***
|
||||
WS Connect Raises NotImplementedError
|
||||
[Documentation] Verify connect raises NotImplementedError until real transport is available
|
||||
${result}= Run Process ${PYTHON} ${HELPER} connect-raises cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} ws-connect-raises-ok
|
||||
|
||||
WS Subscribe Event
|
||||
[Documentation] Verify event subscription and dispatch
|
||||
${result}= Run Process ${PYTHON} ${HELPER} subscribe-event cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} ws-subscribe-event-ok
|
||||
|
||||
WS Dedup Event
|
||||
[Documentation] Verify duplicate event detection
|
||||
${result}= Run Process ${PYTHON} ${HELPER} dedup-event cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} ws-dedup-event-ok
|
||||
|
||||
WS Reconnect
|
||||
[Documentation] Verify reconnection with backoff
|
||||
${result}= Run Process ${PYTHON} ${HELPER} reconnect cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} ws-reconnect-ok
|
||||
|
||||
WS Reconnect Exhaust
|
||||
[Documentation] Verify exhausted reconnects raise error
|
||||
${result}= Run Process ${PYTHON} ${HELPER} reconnect-exhaust cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} ws-reconnect-exhaust-ok
|
||||
|
||||
WS Heartbeat
|
||||
[Documentation] Verify heartbeat resets reconnect counter
|
||||
${result}= Run Process ${PYTHON} ${HELPER} heartbeat cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} ws-heartbeat-ok
|
||||
|
||||
WS Deduplicator
|
||||
[Documentation] Verify EventDeduplicator behavior
|
||||
${result}= Run Process ${PYTHON} ${HELPER} deduplicator cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} ws-deduplicator-ok
|
||||
|
||||
WS Connection State
|
||||
[Documentation] Verify ConnectionState reset
|
||||
${result}= Run Process ${PYTHON} ${HELPER} connection-state cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} ws-connection-state-ok
|
||||
|
||||
WS Version Negotiate
|
||||
[Documentation] Verify version negotiation
|
||||
${result}= Run Process ${PYTHON} ${HELPER} version-negotiate cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} ws-version-negotiate-ok
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Client package for server communication.
|
||||
|
||||
Provides HTTP, sync, WebSocket, and remote project clients
|
||||
for interacting with CleverAgents server instances.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__: list[str] = []
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Client-specific exceptions for server communication.
|
||||
|
||||
Defines typed error hierarchy for HTTP, timeout, and version
|
||||
mismatch failures that arise when communicating with a remote
|
||||
CleverAgents server instance.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from cleveragents.core.exceptions import CleverAgentsError
|
||||
|
||||
|
||||
class ServerConnectionError(CleverAgentsError):
|
||||
"""Raised when a connection to the server cannot be established.
|
||||
|
||||
Attributes:
|
||||
url: The server URL that was unreachable.
|
||||
cause: Optional underlying exception.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
url: str = "",
|
||||
cause: Exception | None = None,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
merged: dict[str, Any] = {"url": url}
|
||||
if cause is not None:
|
||||
merged["cause"] = str(cause)
|
||||
if details:
|
||||
merged.update(details)
|
||||
super().__init__(message, merged)
|
||||
self.url = url
|
||||
self.cause = cause
|
||||
|
||||
|
||||
class ServerTimeoutError(CleverAgentsError):
|
||||
"""Raised when a request to the server exceeds the configured timeout.
|
||||
|
||||
Attributes:
|
||||
url: The endpoint that timed out.
|
||||
timeout_seconds: The configured timeout value.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
url: str = "",
|
||||
timeout_seconds: float = 0.0,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
merged: dict[str, Any] = {
|
||||
"url": url,
|
||||
"timeout_seconds": timeout_seconds,
|
||||
}
|
||||
if details:
|
||||
merged.update(details)
|
||||
super().__init__(message, merged)
|
||||
self.url = url
|
||||
self.timeout_seconds = timeout_seconds
|
||||
|
||||
|
||||
class ServerVersionMismatchError(CleverAgentsError):
|
||||
"""Raised when client and server protocol versions are incompatible.
|
||||
|
||||
Attributes:
|
||||
client_version: The version requested by the client.
|
||||
server_versions: Versions supported by the server.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
client_version: str = "",
|
||||
server_versions: list[str] | None = None,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
merged: dict[str, Any] = {
|
||||
"client_version": client_version,
|
||||
"server_versions": server_versions or [],
|
||||
}
|
||||
if details:
|
||||
merged.update(details)
|
||||
super().__init__(message, merged)
|
||||
self.client_version = client_version
|
||||
self.server_versions = server_versions or []
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ServerConnectionError",
|
||||
"ServerTimeoutError",
|
||||
"ServerVersionMismatchError",
|
||||
]
|
||||
@@ -0,0 +1,522 @@
|
||||
"""Server HTTP client for CleverAgents server communication.
|
||||
|
||||
Provides :class:`ServerHttpClient` which wraps ``httpx`` to communicate
|
||||
with a remote CleverAgents server. Features include health checks,
|
||||
version negotiation, pagination helpers, retry with exponential backoff,
|
||||
request/response logging with auth-header redaction, and a TLS
|
||||
verification toggle.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
|
||||
from cleveragents.a2a.errors import A2aNotAvailableError
|
||||
from cleveragents.a2a.models import A2aVersion
|
||||
from cleveragents.client.exceptions import (
|
||||
ServerConnectionError,
|
||||
ServerTimeoutError,
|
||||
ServerVersionMismatchError,
|
||||
)
|
||||
|
||||
logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_DEFAULT_BASE_URL = "http://localhost:8080"
|
||||
_DEFAULT_TIMEOUT = 30.0
|
||||
_DEFAULT_MAX_RETRIES = 3
|
||||
_DEFAULT_BACKOFF_BASE = 0.5
|
||||
_DEFAULT_BACKOFF_MAX = 30.0
|
||||
_IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"})
|
||||
_AUTH_HEADER = "authorization"
|
||||
_REDACTED = "***REDACTED***"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _redact_headers(headers: dict[str, str]) -> dict[str, str]:
|
||||
"""Return a copy of *headers* with the Authorization value masked."""
|
||||
result: dict[str, str] = {}
|
||||
for key, value in headers.items():
|
||||
if key.lower() == _AUTH_HEADER:
|
||||
result[key] = _REDACTED
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _backoff_delay(attempt: int, base: float, maximum: float) -> float:
|
||||
"""Compute exponential-backoff delay capped at *maximum*."""
|
||||
delay = base * math.pow(2, attempt)
|
||||
return min(delay, maximum)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page result container
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PageResult:
|
||||
"""Container for a single page of results from a list endpoint.
|
||||
|
||||
Attributes:
|
||||
items: The list of items on this page.
|
||||
page: The current page number (1-based).
|
||||
per_page: Number of items per page.
|
||||
total: Total number of items across all pages (``-1`` if unknown).
|
||||
has_next: Whether a next page is available.
|
||||
"""
|
||||
|
||||
__slots__ = ("has_next", "items", "page", "per_page", "total")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
items: list[dict[str, Any]],
|
||||
page: int = 1,
|
||||
per_page: int = 50,
|
||||
total: int = -1,
|
||||
has_next: bool = False,
|
||||
) -> None:
|
||||
self.items = items
|
||||
self.page = page
|
||||
self.per_page = per_page
|
||||
self.total = total
|
||||
self.has_next = has_next
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ServerHttpClient
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ServerHttpClient:
|
||||
"""HTTP client for communicating with a CleverAgents server.
|
||||
|
||||
The client wraps :mod:`httpx` and provides:
|
||||
|
||||
* ``health_check()`` — ``GET /health``
|
||||
* ``get_version()`` — ``GET /version``
|
||||
* ``negotiate_version()`` — ``POST /version/negotiate``
|
||||
* ``list_endpoint()`` — generic paginated ``GET``
|
||||
* Retry with exponential backoff for idempotent methods
|
||||
* Auth-header redaction in logs
|
||||
* TLS verification toggle with a warning when disabled
|
||||
|
||||
Args:
|
||||
base_url: Server root URL.
|
||||
api_token: Bearer token for authentication.
|
||||
tls_verify: Whether to verify TLS certificates.
|
||||
timeout: Per-request timeout in seconds.
|
||||
max_retries: Maximum retry attempts for idempotent calls.
|
||||
backoff_base: Base delay (seconds) for exponential backoff.
|
||||
backoff_max: Maximum backoff delay (seconds).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = _DEFAULT_BASE_URL,
|
||||
api_token: str | None = None,
|
||||
tls_verify: bool = True,
|
||||
timeout: float = _DEFAULT_TIMEOUT,
|
||||
max_retries: int = _DEFAULT_MAX_RETRIES,
|
||||
backoff_base: float = _DEFAULT_BACKOFF_BASE,
|
||||
backoff_max: float = _DEFAULT_BACKOFF_MAX,
|
||||
) -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._api_token = api_token
|
||||
self._tls_verify = tls_verify
|
||||
self._timeout = timeout
|
||||
self._max_retries = max_retries
|
||||
self._backoff_base = backoff_base
|
||||
self._backoff_max = backoff_max
|
||||
|
||||
if not tls_verify:
|
||||
logger.warning(
|
||||
"TLS verification is disabled — connections are not secure",
|
||||
base_url=self._base_url,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_headers(self) -> dict[str, str]:
|
||||
"""Build default request headers including auth if configured."""
|
||||
headers: dict[str, str] = {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if self._api_token:
|
||||
headers["Authorization"] = f"Bearer {self._api_token}"
|
||||
return headers
|
||||
|
||||
def _log_request(self, method: str, url: str, headers: dict[str, str]) -> None:
|
||||
"""Log an outbound request with redacted auth headers."""
|
||||
logger.debug(
|
||||
"server_request",
|
||||
method=method,
|
||||
url=url,
|
||||
headers=_redact_headers(headers),
|
||||
)
|
||||
|
||||
def _log_response(self, method: str, url: str, status: int, elapsed: float) -> None:
|
||||
"""Log a completed response."""
|
||||
logger.debug(
|
||||
"server_response",
|
||||
method=method,
|
||||
url=url,
|
||||
status=status,
|
||||
elapsed_ms=round(elapsed * 1000, 2),
|
||||
)
|
||||
|
||||
def _is_retryable(self, method: str, status_code: int) -> bool:
|
||||
"""Return ``True`` if the request should be retried."""
|
||||
if method.upper() not in _IDEMPOTENT_METHODS:
|
||||
return False
|
||||
return status_code in {429, 500, 502, 504}
|
||||
|
||||
def _map_error_response(self, response: httpx.Response, url: str) -> None:
|
||||
"""Raise a domain exception for non-2xx responses.
|
||||
|
||||
Maps HTTP status codes to client exceptions or
|
||||
:class:`A2aNotAvailableError`.
|
||||
"""
|
||||
status = response.status_code
|
||||
try:
|
||||
body = response.json()
|
||||
except Exception:
|
||||
body = {"raw": response.text}
|
||||
|
||||
retry_hint = ""
|
||||
if status == 429:
|
||||
# The Retry-After header is included in the error message for
|
||||
# diagnostic visibility but is intentionally not used to
|
||||
# override the exponential backoff delay. The client's own
|
||||
# backoff already caps at ``backoff_max`` and using a
|
||||
# server-supplied value could introduce unbounded waits.
|
||||
retry_after = response.headers.get("Retry-After", "")
|
||||
retry_hint = f" (retry after {retry_after}s)" if retry_after else ""
|
||||
|
||||
msg = f"Server returned {status} for {url}{retry_hint}"
|
||||
details: dict[str, Any] = {"status": status, "body": body}
|
||||
|
||||
if status == 503:
|
||||
raise A2aNotAvailableError(
|
||||
message=msg,
|
||||
details=details,
|
||||
)
|
||||
if status in {502, 504}:
|
||||
raise ServerConnectionError(
|
||||
message=msg,
|
||||
url=url,
|
||||
details=details,
|
||||
)
|
||||
if status == 401 or status == 403:
|
||||
raise ServerConnectionError(
|
||||
message=f"Authentication failed ({status}) for {url}",
|
||||
url=url,
|
||||
details=details,
|
||||
)
|
||||
raise ServerConnectionError(message=msg, url=url, details=details)
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
json_body: dict[str, Any] | None = None,
|
||||
params: dict[str, str] | None = None,
|
||||
) -> httpx.Response:
|
||||
"""Execute an HTTP request with retry for idempotent methods.
|
||||
|
||||
Args:
|
||||
method: HTTP method (GET, POST, PUT, DELETE, etc.).
|
||||
path: URL path relative to the base URL.
|
||||
json_body: Optional JSON request body.
|
||||
params: Optional query parameters.
|
||||
|
||||
Returns:
|
||||
The successful ``httpx.Response``.
|
||||
|
||||
Raises:
|
||||
ServerConnectionError: On connection or HTTP failure.
|
||||
ServerTimeoutError: On request timeout.
|
||||
A2aNotAvailableError: When the server returns 503.
|
||||
"""
|
||||
url = f"{self._base_url}{path}"
|
||||
headers = self._build_headers()
|
||||
self._log_request(method, url, headers)
|
||||
|
||||
last_exc: Exception | None = None
|
||||
attempts = self._max_retries if method.upper() in _IDEMPOTENT_METHODS else 1
|
||||
|
||||
for attempt in range(attempts):
|
||||
start = time.monotonic()
|
||||
try:
|
||||
response = httpx.request(
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
json=json_body,
|
||||
params=params,
|
||||
timeout=self._timeout,
|
||||
verify=self._tls_verify,
|
||||
)
|
||||
elapsed = time.monotonic() - start
|
||||
self._log_response(method, url, response.status_code, elapsed)
|
||||
|
||||
if response.is_success:
|
||||
return response
|
||||
|
||||
if self._is_retryable(method, response.status_code):
|
||||
last_exc = ServerConnectionError(
|
||||
message=f"Retryable {response.status_code}",
|
||||
url=url,
|
||||
)
|
||||
delay = _backoff_delay(
|
||||
attempt, self._backoff_base, self._backoff_max
|
||||
)
|
||||
logger.info(
|
||||
"retrying_request",
|
||||
attempt=attempt + 1,
|
||||
delay=delay,
|
||||
status=response.status_code,
|
||||
)
|
||||
# Blocking sleep is intentional: this is a synchronous
|
||||
# client and callers expect blocking semantics. An
|
||||
# async variant should use ``asyncio.sleep`` instead.
|
||||
time.sleep(delay)
|
||||
continue
|
||||
|
||||
self._map_error_response(response, url)
|
||||
|
||||
except httpx.TimeoutException as exc:
|
||||
last_exc = ServerTimeoutError(
|
||||
message=f"Request to {url} timed out after {self._timeout}s",
|
||||
url=url,
|
||||
timeout_seconds=self._timeout,
|
||||
)
|
||||
if attempt + 1 < attempts:
|
||||
delay = _backoff_delay(
|
||||
attempt, self._backoff_base, self._backoff_max
|
||||
)
|
||||
logger.info(
|
||||
"retrying_timeout",
|
||||
attempt=attempt + 1,
|
||||
delay=delay,
|
||||
)
|
||||
time.sleep(delay)
|
||||
continue
|
||||
raise last_exc from exc
|
||||
|
||||
except httpx.ConnectError as exc:
|
||||
last_exc = ServerConnectionError(
|
||||
message=f"Cannot connect to {url}",
|
||||
url=url,
|
||||
cause=exc,
|
||||
)
|
||||
if attempt + 1 < attempts:
|
||||
delay = _backoff_delay(
|
||||
attempt, self._backoff_base, self._backoff_max
|
||||
)
|
||||
logger.info(
|
||||
"retrying_connection",
|
||||
attempt=attempt + 1,
|
||||
delay=delay,
|
||||
)
|
||||
time.sleep(delay)
|
||||
continue
|
||||
raise last_exc from exc
|
||||
|
||||
except (ServerConnectionError, ServerTimeoutError, A2aNotAvailableError):
|
||||
raise
|
||||
|
||||
except httpx.HTTPError as exc:
|
||||
raise ServerConnectionError(
|
||||
message=f"HTTP error for {url}: {exc}",
|
||||
url=url,
|
||||
cause=exc,
|
||||
) from exc
|
||||
|
||||
# Exhausted retries
|
||||
if last_exc is not None:
|
||||
raise last_exc
|
||||
raise ServerConnectionError( # pragma: no cover
|
||||
message=f"Request to {url} failed after {attempts} attempts",
|
||||
url=url,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def health_check(self) -> bool:
|
||||
"""Check server health via ``GET /health``.
|
||||
|
||||
Returns:
|
||||
``True`` if the server reports a healthy status.
|
||||
|
||||
Raises:
|
||||
ServerConnectionError: On connection failure.
|
||||
ServerTimeoutError: On request timeout.
|
||||
"""
|
||||
try:
|
||||
response = self.request("GET", "/health")
|
||||
data = response.json()
|
||||
return bool(data.get("status") == "healthy")
|
||||
except (ServerConnectionError, ServerTimeoutError, A2aNotAvailableError):
|
||||
return False
|
||||
|
||||
def get_version(self) -> str:
|
||||
"""Retrieve the server version string via ``GET /version``.
|
||||
|
||||
Returns:
|
||||
The semantic version string reported by the server.
|
||||
|
||||
Raises:
|
||||
ServerConnectionError: On connection failure.
|
||||
ServerTimeoutError: On request timeout.
|
||||
"""
|
||||
response = self.request("GET", "/version")
|
||||
data = response.json()
|
||||
version: str = str(data.get("version", ""))
|
||||
return version
|
||||
|
||||
def negotiate_version(self, client_version: str = A2aVersion.CURRENT) -> str:
|
||||
"""Negotiate an ACP version with the server.
|
||||
|
||||
Posts the client's preferred version and returns the version
|
||||
agreed upon by the server.
|
||||
|
||||
Args:
|
||||
client_version: The ACP version this client supports.
|
||||
|
||||
Returns:
|
||||
The negotiated version string.
|
||||
|
||||
Raises:
|
||||
ServerVersionMismatchError: When negotiation fails.
|
||||
ServerConnectionError: On connection failure.
|
||||
"""
|
||||
response = self.request(
|
||||
"POST",
|
||||
"/version/negotiate",
|
||||
json_body={"client_version": client_version},
|
||||
)
|
||||
data = response.json()
|
||||
negotiated = str(data.get("negotiated_version", ""))
|
||||
if not negotiated:
|
||||
raise ServerVersionMismatchError(
|
||||
message="Server did not return a negotiated version",
|
||||
client_version=client_version,
|
||||
server_versions=data.get("supported_versions", []),
|
||||
)
|
||||
return negotiated
|
||||
|
||||
def list_endpoint(
|
||||
self,
|
||||
path: str,
|
||||
*,
|
||||
page: int = 1,
|
||||
per_page: int = 50,
|
||||
params: dict[str, str] | None = None,
|
||||
) -> PageResult:
|
||||
"""Fetch a paginated list endpoint.
|
||||
|
||||
Args:
|
||||
path: API path (e.g. ``/plans``).
|
||||
page: Page number (1-based).
|
||||
per_page: Items per page.
|
||||
params: Additional query parameters.
|
||||
|
||||
Returns:
|
||||
A :class:`PageResult` with the decoded items.
|
||||
"""
|
||||
query: dict[str, str] = {"page": str(page), "per_page": str(per_page)}
|
||||
if params:
|
||||
query.update(params)
|
||||
response = self.request("GET", path, params=query)
|
||||
data = response.json()
|
||||
|
||||
items: list[dict[str, Any]]
|
||||
total: int
|
||||
has_next: bool
|
||||
if isinstance(data, list):
|
||||
items = data
|
||||
total = -1
|
||||
has_next = len(items) >= per_page
|
||||
else:
|
||||
items = data.get("items", [])
|
||||
total = int(data.get("total", -1))
|
||||
has_next = bool(data.get("has_next", len(items) >= per_page))
|
||||
|
||||
return PageResult(
|
||||
items=items,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
total=total,
|
||||
has_next=has_next,
|
||||
)
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
"""The server base URL."""
|
||||
return self._base_url
|
||||
|
||||
@property
|
||||
def tls_verify(self) -> bool:
|
||||
"""Whether TLS verification is enabled."""
|
||||
return self._tls_verify
|
||||
|
||||
@property
|
||||
def timeout(self) -> float:
|
||||
"""Per-request timeout in seconds."""
|
||||
return self._timeout
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release any held resources (no-op for stateless client)."""
|
||||
logger.debug("server_http_client_closed", base_url=self._base_url)
|
||||
|
||||
|
||||
def create_client_from_settings() -> ServerHttpClient:
|
||||
"""Create a :class:`ServerHttpClient` from application settings.
|
||||
|
||||
Returns:
|
||||
A configured client instance.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``server_base_url`` is not configured.
|
||||
"""
|
||||
from cleveragents.config.settings import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
base_url = settings.server_base_url
|
||||
if not base_url:
|
||||
raise ValueError(
|
||||
"server_base_url is not configured. "
|
||||
"Set CLEVERAGENTS_SERVER_BASE_URL to the server address."
|
||||
)
|
||||
return ServerHttpClient(
|
||||
base_url=base_url,
|
||||
api_token=settings.server_api_token,
|
||||
tls_verify=settings.server_tls_verify,
|
||||
timeout=settings.server_request_timeout,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PageResult",
|
||||
"ServerHttpClient",
|
||||
"create_client_from_settings",
|
||||
]
|
||||
@@ -0,0 +1,298 @@
|
||||
"""Remote project client for server-hosted project access.
|
||||
|
||||
Provides :class:`RemoteProjectClient` for listing, resolving, and
|
||||
requesting execution of projects hosted on a remote CleverAgents
|
||||
server. Features include namespace-aware project-name resolution,
|
||||
TTL-based caching, and explicit errors when a remote project is
|
||||
not found or inaccessible.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
|
||||
from cleveragents.client.http_client import ServerHttpClient
|
||||
from cleveragents.core.exceptions import ResourceNotFoundError
|
||||
|
||||
logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_DEFAULT_CACHE_TTL = 300.0 # 5 minutes
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cache entry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _CacheEntry:
|
||||
"""Internal cache entry with expiry tracking."""
|
||||
|
||||
data: dict[str, Any]
|
||||
expires_at: float
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Remote project metadata
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RemoteProject:
|
||||
"""Metadata for a project hosted on a remote server.
|
||||
|
||||
Attributes:
|
||||
project_id: Server-assigned project identifier.
|
||||
name: Human-readable project name.
|
||||
namespace: Server namespace the project belongs to.
|
||||
alias: Optional short alias for CLI usage.
|
||||
description: Project description.
|
||||
"""
|
||||
|
||||
project_id: str
|
||||
name: str
|
||||
namespace: str = "default"
|
||||
alias: str = ""
|
||||
description: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RemoteProjectClient
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RemoteProjectClient:
|
||||
"""Client for accessing and executing remote server-hosted projects.
|
||||
|
||||
Args:
|
||||
http_client: The underlying HTTP client for server communication.
|
||||
cache_ttl: TTL in seconds for the project list cache.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
http_client: ServerHttpClient,
|
||||
cache_ttl: float = _DEFAULT_CACHE_TTL,
|
||||
) -> None:
|
||||
self._http = http_client
|
||||
self._cache_ttl = cache_ttl
|
||||
self._cache: dict[str, _CacheEntry] = {}
|
||||
|
||||
@property
|
||||
def cache_ttl(self) -> float:
|
||||
"""Cache TTL in seconds."""
|
||||
return self._cache_ttl
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Project listing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_projects(
|
||||
self,
|
||||
namespace: str = "default",
|
||||
*,
|
||||
force_refresh: bool = False,
|
||||
) -> list[RemoteProject]:
|
||||
"""List remote projects in a namespace.
|
||||
|
||||
Results are cached for :attr:`cache_ttl` seconds unless
|
||||
*force_refresh* is ``True``.
|
||||
|
||||
Args:
|
||||
namespace: Server namespace to query.
|
||||
force_refresh: Bypass the cache and fetch from server.
|
||||
|
||||
Returns:
|
||||
List of :class:`RemoteProject` instances.
|
||||
|
||||
Raises:
|
||||
ServerConnectionError: On server communication failure.
|
||||
"""
|
||||
cache_key = f"projects:{namespace}"
|
||||
|
||||
if not force_refresh:
|
||||
cached = self._get_cached(cache_key)
|
||||
if cached is not None:
|
||||
return self._parse_projects(cached, namespace)
|
||||
|
||||
page_result = self._http.list_endpoint(
|
||||
"/projects",
|
||||
params={"namespace": namespace},
|
||||
)
|
||||
items = page_result.items
|
||||
self._set_cached(cache_key, items)
|
||||
return self._parse_projects(items, namespace)
|
||||
|
||||
def get_project(
|
||||
self,
|
||||
project_name: str,
|
||||
namespace: str = "default",
|
||||
) -> RemoteProject:
|
||||
"""Resolve a project by name within a namespace.
|
||||
|
||||
Args:
|
||||
project_name: The project name or alias to look up.
|
||||
namespace: Server namespace.
|
||||
|
||||
Returns:
|
||||
The matched :class:`RemoteProject`.
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundError: When the project is not found.
|
||||
ServerConnectionError: On server communication failure.
|
||||
"""
|
||||
if not project_name:
|
||||
raise ValueError("project_name must be a non-empty string")
|
||||
|
||||
projects = self.list_projects(namespace)
|
||||
for project in projects:
|
||||
if project.name == project_name or project.alias == project_name:
|
||||
return project
|
||||
|
||||
raise ResourceNotFoundError(
|
||||
message=(
|
||||
f"Remote project '{project_name}' not found in namespace '{namespace}'"
|
||||
),
|
||||
resource_type="project",
|
||||
resource_id=project_name,
|
||||
)
|
||||
|
||||
def resolve_project(
|
||||
self,
|
||||
name_or_alias: str,
|
||||
namespace: str = "default",
|
||||
) -> RemoteProject:
|
||||
"""Resolve a project name or alias, checking all namespaces.
|
||||
|
||||
First checks the specified namespace, then falls back to
|
||||
the ``"default"`` namespace if different.
|
||||
|
||||
Args:
|
||||
name_or_alias: Project name or alias.
|
||||
namespace: Preferred namespace.
|
||||
|
||||
Returns:
|
||||
The matched :class:`RemoteProject`.
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundError: When the project is not found.
|
||||
"""
|
||||
if not name_or_alias:
|
||||
raise ValueError("name_or_alias must be a non-empty string")
|
||||
|
||||
try:
|
||||
return self.get_project(name_or_alias, namespace)
|
||||
except ResourceNotFoundError:
|
||||
if namespace != "default":
|
||||
return self.get_project(name_or_alias, "default")
|
||||
raise
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Execution
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def request_execution(
|
||||
self,
|
||||
project_id: str,
|
||||
plan_name: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Request execution of a remote project plan.
|
||||
|
||||
Args:
|
||||
project_id: Server project identifier.
|
||||
plan_name: Optional plan name (uses default if empty).
|
||||
|
||||
Returns:
|
||||
Execution metadata from the server.
|
||||
|
||||
Raises:
|
||||
ServerConnectionError: On server communication failure.
|
||||
"""
|
||||
if not project_id:
|
||||
raise ValueError("project_id must be a non-empty string")
|
||||
|
||||
resp = self._http.request(
|
||||
"POST",
|
||||
f"/projects/{project_id}/execute",
|
||||
json_body={"plan_name": plan_name} if plan_name else None,
|
||||
)
|
||||
data: dict[str, Any] = resp.json()
|
||||
return data
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Cache management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def invalidate_cache(self, namespace: str | None = None) -> None:
|
||||
"""Invalidate cached project data.
|
||||
|
||||
Args:
|
||||
namespace: If given, only invalidate that namespace.
|
||||
If ``None``, clear the entire cache.
|
||||
"""
|
||||
if namespace is None:
|
||||
self._cache.clear()
|
||||
logger.debug("remote_project_cache_cleared")
|
||||
else:
|
||||
key = f"projects:{namespace}"
|
||||
self._cache.pop(key, None)
|
||||
logger.debug(
|
||||
"remote_project_cache_invalidated",
|
||||
namespace=namespace,
|
||||
)
|
||||
|
||||
@property
|
||||
def cache_size(self) -> int:
|
||||
"""Number of cached entries."""
|
||||
return len(self._cache)
|
||||
|
||||
def _get_cached(self, key: str) -> list[dict[str, Any]] | None:
|
||||
"""Return cached data if still valid, else ``None``."""
|
||||
entry = self._cache.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
if time.monotonic() > entry.expires_at:
|
||||
del self._cache[key]
|
||||
return None
|
||||
cached: list[dict[str, Any]] = entry.data.get("items", [])
|
||||
return cached
|
||||
|
||||
def _set_cached(self, key: str, items: list[dict[str, Any]]) -> None:
|
||||
"""Store data in cache with TTL."""
|
||||
self._cache[key] = _CacheEntry(
|
||||
data={"items": items},
|
||||
expires_at=time.monotonic() + self._cache_ttl,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_projects(
|
||||
items: list[dict[str, Any]],
|
||||
namespace: str,
|
||||
) -> list[RemoteProject]:
|
||||
"""Convert raw dicts to :class:`RemoteProject` instances."""
|
||||
projects: list[RemoteProject] = []
|
||||
for item in items:
|
||||
projects.append(
|
||||
RemoteProject(
|
||||
project_id=str(item.get("id", "")),
|
||||
name=str(item.get("name", "")),
|
||||
namespace=namespace,
|
||||
alias=str(item.get("alias", "")),
|
||||
description=str(item.get("description", "")),
|
||||
)
|
||||
)
|
||||
return projects
|
||||
|
||||
|
||||
__all__ = [
|
||||
"RemoteProject",
|
||||
"RemoteProjectClient",
|
||||
]
|
||||
@@ -0,0 +1,380 @@
|
||||
"""Plan sync and remote execution client.
|
||||
|
||||
Provides :class:`PlanSyncClient` for synchronizing local plans, actions,
|
||||
skills, tools, and projects with a remote CleverAgents server. Supports
|
||||
conflict resolution policies, scope flags, dry-run mode, and server-side
|
||||
ID persistence.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
|
||||
from cleveragents.a2a.errors import A2aNotAvailableError
|
||||
from cleveragents.client.exceptions import ServerConnectionError, ServerTimeoutError
|
||||
from cleveragents.client.http_client import ServerHttpClient
|
||||
|
||||
logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Conflict resolution policy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ConflictPolicy(Enum):
|
||||
"""Strategy for resolving conflicts between local and server state."""
|
||||
|
||||
LOCAL_WINS = "local_wins"
|
||||
SERVER_WINS = "server_wins"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sync scope
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SyncScope:
|
||||
"""Flags controlling which resource types are included in a sync.
|
||||
|
||||
Attributes:
|
||||
actions: Sync action definitions.
|
||||
skills: Sync skill definitions.
|
||||
tools: Sync tool definitions.
|
||||
projects: Sync project metadata.
|
||||
"""
|
||||
|
||||
actions: bool = True
|
||||
skills: bool = True
|
||||
tools: bool = True
|
||||
projects: bool = True
|
||||
|
||||
def active_types(self) -> list[str]:
|
||||
"""Return the list of active resource type names."""
|
||||
types: list[str] = []
|
||||
if self.actions:
|
||||
types.append("actions")
|
||||
if self.skills:
|
||||
types.append("skills")
|
||||
if self.tools:
|
||||
types.append("tools")
|
||||
if self.projects:
|
||||
types.append("projects")
|
||||
return types
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sync summary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SyncSummary:
|
||||
"""Summary of a sync operation.
|
||||
|
||||
Attributes:
|
||||
created: Number of items created on the server.
|
||||
updated: Number of items updated on the server.
|
||||
skipped: Number of items skipped (no change or conflict).
|
||||
errors: Number of items that failed to sync.
|
||||
resource_type: The resource type that was synced.
|
||||
dry_run: Whether this was a dry-run (no actual changes).
|
||||
server_ids: Mapping of local IDs to server-assigned IDs.
|
||||
"""
|
||||
|
||||
created: int = 0
|
||||
updated: int = 0
|
||||
skipped: int = 0
|
||||
errors: int = 0
|
||||
resource_type: str = ""
|
||||
dry_run: bool = False
|
||||
server_ids: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def total_processed(self) -> int:
|
||||
"""Total items processed."""
|
||||
return self.created + self.updated + self.skipped + self.errors
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Execution result
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ExecutionResult:
|
||||
"""Result of a remote plan execution request.
|
||||
|
||||
Attributes:
|
||||
plan_id: The local plan identifier.
|
||||
server_plan_id: The server-assigned execution ID.
|
||||
status: Current execution status.
|
||||
message: Optional status message.
|
||||
"""
|
||||
|
||||
plan_id: str
|
||||
server_plan_id: str
|
||||
status: str
|
||||
message: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PlanSyncClient
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PlanSyncClient:
|
||||
"""Client for syncing plans and resources with a remote server.
|
||||
|
||||
Args:
|
||||
http_client: The underlying HTTP client for server communication.
|
||||
conflict_policy: Default conflict resolution strategy.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
http_client: ServerHttpClient,
|
||||
conflict_policy: ConflictPolicy = ConflictPolicy.LOCAL_WINS,
|
||||
) -> None:
|
||||
self._http = http_client
|
||||
self._conflict_policy = conflict_policy
|
||||
|
||||
@property
|
||||
def conflict_policy(self) -> ConflictPolicy:
|
||||
"""Current conflict resolution policy."""
|
||||
return self._conflict_policy
|
||||
|
||||
@conflict_policy.setter
|
||||
def conflict_policy(self, value: ConflictPolicy) -> None:
|
||||
self._conflict_policy = value
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Sync operations
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def sync(
|
||||
self,
|
||||
items: list[dict[str, Any]],
|
||||
resource_type: str,
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
conflict_policy: ConflictPolicy | None = None,
|
||||
) -> SyncSummary:
|
||||
"""Synchronize a list of local items with the server.
|
||||
|
||||
Args:
|
||||
items: Local resource dicts with at least an ``"id"`` key.
|
||||
resource_type: One of ``"actions"``, ``"skills"``,
|
||||
``"tools"``, ``"projects"``.
|
||||
dry_run: When ``True`` no server mutations occur.
|
||||
conflict_policy: Override the default conflict policy.
|
||||
|
||||
Returns:
|
||||
A :class:`SyncSummary` describing what was (or would be) done.
|
||||
"""
|
||||
policy = conflict_policy or self._conflict_policy
|
||||
summary = SyncSummary(resource_type=resource_type, dry_run=dry_run)
|
||||
|
||||
logger.info(
|
||||
"sync_start",
|
||||
resource_type=resource_type,
|
||||
item_count=len(items),
|
||||
dry_run=dry_run,
|
||||
policy=policy.value,
|
||||
)
|
||||
|
||||
for item in items:
|
||||
local_id = str(item.get("id", ""))
|
||||
if not local_id:
|
||||
summary.errors += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
result = self._sync_item(
|
||||
item, resource_type, policy=policy, dry_run=dry_run
|
||||
)
|
||||
if result == "created":
|
||||
summary.created += 1
|
||||
elif result == "updated":
|
||||
summary.updated += 1
|
||||
else:
|
||||
summary.skipped += 1
|
||||
|
||||
server_id = str(item.get("server_id", local_id))
|
||||
summary.server_ids[local_id] = server_id
|
||||
|
||||
except (ServerConnectionError, ServerTimeoutError, A2aNotAvailableError):
|
||||
summary.errors += 1
|
||||
logger.warning("sync_item_failed", local_id=local_id)
|
||||
|
||||
logger.info(
|
||||
"sync_complete",
|
||||
resource_type=resource_type,
|
||||
created=summary.created,
|
||||
updated=summary.updated,
|
||||
skipped=summary.skipped,
|
||||
errors=summary.errors,
|
||||
)
|
||||
return summary
|
||||
|
||||
def sync_all(
|
||||
self,
|
||||
resources: dict[str, list[dict[str, Any]]],
|
||||
scope: SyncScope | None = None,
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
) -> dict[str, SyncSummary]:
|
||||
"""Synchronize multiple resource types according to scope flags.
|
||||
|
||||
Args:
|
||||
resources: Mapping of resource type name to list of items.
|
||||
scope: A :class:`SyncScope` controlling which types to sync.
|
||||
dry_run: When ``True`` no server mutations occur.
|
||||
|
||||
Returns:
|
||||
Mapping of resource type to its :class:`SyncSummary`.
|
||||
"""
|
||||
scope = scope or SyncScope()
|
||||
results: dict[str, SyncSummary] = {}
|
||||
for rtype in scope.active_types():
|
||||
items = resources.get(rtype, [])
|
||||
results[rtype] = self.sync(items, rtype, dry_run=dry_run)
|
||||
return results
|
||||
|
||||
def _sync_item(
|
||||
self,
|
||||
item: dict[str, Any],
|
||||
resource_type: str,
|
||||
*,
|
||||
policy: ConflictPolicy,
|
||||
dry_run: bool,
|
||||
) -> str:
|
||||
"""Sync a single item. Returns 'created', 'updated', or 'skipped'."""
|
||||
local_id = str(item.get("id", ""))
|
||||
server_id = item.get("server_id")
|
||||
|
||||
if dry_run:
|
||||
return "created" if server_id is None else "updated"
|
||||
|
||||
if server_id is None:
|
||||
# New item — create on server
|
||||
resp = self._http.request(
|
||||
"POST",
|
||||
f"/{resource_type}",
|
||||
json_body={"item": item},
|
||||
)
|
||||
data = resp.json()
|
||||
new_server_id = data.get("id", local_id)
|
||||
item["server_id"] = new_server_id
|
||||
return "created"
|
||||
|
||||
# Existing item — check for conflict
|
||||
try:
|
||||
resp = self._http.request(
|
||||
"GET",
|
||||
f"/{resource_type}/{server_id}",
|
||||
)
|
||||
server_data = resp.json()
|
||||
except ServerConnectionError:
|
||||
raise
|
||||
|
||||
server_version = server_data.get("version", 0)
|
||||
local_version = item.get("version", 0)
|
||||
|
||||
if server_version == local_version:
|
||||
return "skipped"
|
||||
|
||||
if policy == ConflictPolicy.SERVER_WINS and server_version > local_version:
|
||||
return "skipped"
|
||||
|
||||
# Local wins or local version is newer — push update
|
||||
resp = self._http.request(
|
||||
"PUT",
|
||||
f"/{resource_type}/{server_id}",
|
||||
json_body={"item": item},
|
||||
)
|
||||
return "updated"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Remote execution
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def execute_plan(self, plan_id: str) -> ExecutionResult:
|
||||
"""Submit a plan for remote execution.
|
||||
|
||||
Args:
|
||||
plan_id: Local plan identifier.
|
||||
|
||||
Returns:
|
||||
An :class:`ExecutionResult` with the server-assigned execution ID.
|
||||
"""
|
||||
if not plan_id:
|
||||
raise ValueError("plan_id must be a non-empty string")
|
||||
|
||||
resp = self._http.request(
|
||||
"POST",
|
||||
"/plans/execute",
|
||||
json_body={"plan_id": plan_id},
|
||||
)
|
||||
data = resp.json()
|
||||
return ExecutionResult(
|
||||
plan_id=plan_id,
|
||||
server_plan_id=str(data.get("server_plan_id", "")),
|
||||
status=str(data.get("status", "submitted")),
|
||||
message=str(data.get("message", "")),
|
||||
)
|
||||
|
||||
def apply_plan(self, plan_id: str) -> ExecutionResult:
|
||||
"""Apply a plan on the remote server.
|
||||
|
||||
Args:
|
||||
plan_id: Local plan identifier.
|
||||
|
||||
Returns:
|
||||
An :class:`ExecutionResult` with execution metadata.
|
||||
"""
|
||||
if not plan_id:
|
||||
raise ValueError("plan_id must be a non-empty string")
|
||||
|
||||
resp = self._http.request(
|
||||
"POST",
|
||||
"/plans/apply",
|
||||
json_body={"plan_id": plan_id},
|
||||
)
|
||||
data = resp.json()
|
||||
return ExecutionResult(
|
||||
plan_id=plan_id,
|
||||
server_plan_id=str(data.get("server_plan_id", "")),
|
||||
status=str(data.get("status", "applying")),
|
||||
message=str(data.get("message", "")),
|
||||
)
|
||||
|
||||
def get_plan_status(self, server_plan_id: str) -> dict[str, Any]:
|
||||
"""Query the execution status of a remote plan.
|
||||
|
||||
Args:
|
||||
server_plan_id: Server-assigned plan execution ID.
|
||||
|
||||
Returns:
|
||||
Status metadata dict with phase, progress, and elapsed time.
|
||||
"""
|
||||
if not server_plan_id:
|
||||
raise ValueError("server_plan_id must be a non-empty string")
|
||||
|
||||
resp = self._http.request("GET", f"/plans/{server_plan_id}/status")
|
||||
data: dict[str, Any] = resp.json()
|
||||
return data
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ConflictPolicy",
|
||||
"ExecutionResult",
|
||||
"PlanSyncClient",
|
||||
"SyncScope",
|
||||
"SyncSummary",
|
||||
]
|
||||
@@ -0,0 +1,364 @@
|
||||
"""WebSocket client for real-time plan updates.
|
||||
|
||||
Provides :class:`WebSocketClient` for subscribing to plan update events
|
||||
from a CleverAgents server over a WebSocket connection. Features include
|
||||
automatic reconnection with exponential backoff, heartbeat/ping handling,
|
||||
event de-duplication by ``event_id``, ordered delivery guarantees, and
|
||||
resume-from-last-event-ID on reconnect.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Callable
|
||||
|
||||
import structlog
|
||||
|
||||
from cleveragents.a2a.models import A2aEvent
|
||||
from cleveragents.client.exceptions import ServerConnectionError
|
||||
|
||||
logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_DEFAULT_WS_URL = "ws://localhost:8080/ws"
|
||||
_DEFAULT_RECONNECT_BASE = 1.0
|
||||
_DEFAULT_RECONNECT_MAX = 60.0
|
||||
_DEFAULT_MAX_RECONNECTS = 10
|
||||
_DEFAULT_HEARTBEAT_INTERVAL = 30.0
|
||||
_DEFAULT_DEDUP_CAPACITY = 1000
|
||||
|
||||
EventCallback = Callable[[A2aEvent], None]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backoff helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _ws_backoff_delay(attempt: int, base: float, maximum: float) -> float:
|
||||
"""Compute exponential-backoff delay capped at *maximum*."""
|
||||
return min(base * math.pow(2, attempt), maximum)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WebSocket connection state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ConnectionState:
|
||||
"""Tracks WebSocket connection lifecycle state.
|
||||
|
||||
Attributes:
|
||||
connected: Whether the WebSocket is currently connected.
|
||||
reconnect_count: Number of reconnection attempts so far.
|
||||
last_event_id: ID of the last successfully processed event.
|
||||
negotiated_version: Protocol version agreed with the server.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"connected",
|
||||
"last_event_id",
|
||||
"negotiated_version",
|
||||
"reconnect_count",
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.connected: bool = False
|
||||
self.reconnect_count: int = 0
|
||||
self.last_event_id: str = ""
|
||||
self.negotiated_version: str = ""
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset to initial disconnected state."""
|
||||
self.connected = False
|
||||
self.reconnect_count = 0
|
||||
self.last_event_id = ""
|
||||
self.negotiated_version = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Event de-duplication buffer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class EventDeduplicator:
|
||||
"""LRU-bounded set for de-duplicating events by ``event_id``.
|
||||
|
||||
Keeps at most *capacity* event IDs. When the buffer is full the
|
||||
oldest entry is evicted.
|
||||
|
||||
Args:
|
||||
capacity: Maximum number of event IDs to remember.
|
||||
"""
|
||||
|
||||
def __init__(self, capacity: int = _DEFAULT_DEDUP_CAPACITY) -> None:
|
||||
self._capacity = capacity
|
||||
self._seen: OrderedDict[str, None] = OrderedDict()
|
||||
|
||||
@property
|
||||
def capacity(self) -> int:
|
||||
"""Maximum number of tracked event IDs."""
|
||||
return self._capacity
|
||||
|
||||
def is_duplicate(self, event_id: str) -> bool:
|
||||
"""Return ``True`` if *event_id* was already seen."""
|
||||
if event_id in self._seen:
|
||||
return True
|
||||
self._seen[event_id] = None
|
||||
if len(self._seen) > self._capacity:
|
||||
self._seen.popitem(last=False)
|
||||
return False
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Remove all tracked event IDs."""
|
||||
self._seen.clear()
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
"""Number of event IDs currently tracked."""
|
||||
return len(self._seen)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WebSocketClient
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class WebSocketClient:
|
||||
"""Client for receiving real-time plan update events via WebSocket.
|
||||
|
||||
The client manages connection lifecycle including automatic
|
||||
reconnection with exponential backoff, heartbeat handling, event
|
||||
de-duplication, and ordered delivery.
|
||||
|
||||
Args:
|
||||
ws_url: WebSocket server URL.
|
||||
api_token: Bearer token for authentication.
|
||||
reconnect_base: Base delay for exponential backoff (seconds).
|
||||
reconnect_max: Maximum backoff delay (seconds).
|
||||
max_reconnects: Maximum reconnection attempts before giving up.
|
||||
heartbeat_interval: Expected heartbeat interval (seconds).
|
||||
dedup_capacity: Maximum tracked event IDs for de-duplication.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ws_url: str = _DEFAULT_WS_URL,
|
||||
api_token: str | None = None,
|
||||
reconnect_base: float = _DEFAULT_RECONNECT_BASE,
|
||||
reconnect_max: float = _DEFAULT_RECONNECT_MAX,
|
||||
max_reconnects: int = _DEFAULT_MAX_RECONNECTS,
|
||||
heartbeat_interval: float = _DEFAULT_HEARTBEAT_INTERVAL,
|
||||
dedup_capacity: int = _DEFAULT_DEDUP_CAPACITY,
|
||||
) -> None:
|
||||
self._ws_url = ws_url
|
||||
self._api_token = api_token
|
||||
self._reconnect_base = reconnect_base
|
||||
self._reconnect_max = reconnect_max
|
||||
self._max_reconnects = max_reconnects
|
||||
self._heartbeat_interval = heartbeat_interval
|
||||
|
||||
self._state = ConnectionState()
|
||||
self._dedup = EventDeduplicator(capacity=dedup_capacity)
|
||||
self._callbacks: list[EventCallback] = []
|
||||
self._lock = threading.Lock()
|
||||
self._running = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Properties
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def ws_url(self) -> str:
|
||||
"""The WebSocket server URL."""
|
||||
return self._ws_url
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
"""Whether the client is currently connected."""
|
||||
return self._state.connected
|
||||
|
||||
@property
|
||||
def reconnect_count(self) -> int:
|
||||
"""Number of reconnection attempts so far."""
|
||||
return self._state.reconnect_count
|
||||
|
||||
@property
|
||||
def last_event_id(self) -> str:
|
||||
"""ID of the last successfully processed event."""
|
||||
return self._state.last_event_id
|
||||
|
||||
@property
|
||||
def heartbeat_interval(self) -> float:
|
||||
"""Expected heartbeat interval in seconds."""
|
||||
return self._heartbeat_interval
|
||||
|
||||
@property
|
||||
def dedup_capacity(self) -> int:
|
||||
"""Maximum tracked event IDs for de-duplication."""
|
||||
return self._dedup.capacity
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Subscription
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def subscribe(self, callback: EventCallback) -> None:
|
||||
"""Register a callback for plan update events.
|
||||
|
||||
Args:
|
||||
callback: Function called with each new :class:`A2aEvent`.
|
||||
"""
|
||||
with self._lock:
|
||||
self._callbacks.append(callback)
|
||||
logger.debug("ws_subscriber_added", total=len(self._callbacks))
|
||||
|
||||
def unsubscribe(self, callback: EventCallback) -> None:
|
||||
"""Remove a previously registered callback.
|
||||
|
||||
Args:
|
||||
callback: The callback to remove.
|
||||
"""
|
||||
with self._lock:
|
||||
self._callbacks = [cb for cb in self._callbacks if cb is not callback]
|
||||
logger.debug("ws_subscriber_removed", total=len(self._callbacks))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event processing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def process_event(self, event: A2aEvent) -> bool:
|
||||
"""Process and dispatch an event to subscribers.
|
||||
|
||||
Performs de-duplication and updates the last event ID.
|
||||
|
||||
Args:
|
||||
event: The event to process.
|
||||
|
||||
Returns:
|
||||
``True`` if the event was dispatched, ``False`` if it was
|
||||
a duplicate.
|
||||
"""
|
||||
if self._dedup.is_duplicate(event.event_id):
|
||||
logger.debug("ws_event_duplicate", event_id=event.event_id)
|
||||
return False
|
||||
|
||||
with self._lock:
|
||||
self._state.last_event_id = event.event_id
|
||||
callbacks = list(self._callbacks)
|
||||
|
||||
for cb in callbacks:
|
||||
try:
|
||||
cb(event)
|
||||
except Exception:
|
||||
logger.exception("ws_callback_error", event_id=event.event_id)
|
||||
|
||||
return True
|
||||
|
||||
def handle_heartbeat(self) -> None:
|
||||
"""Handle a heartbeat/ping from the server.
|
||||
|
||||
Resets the reconnect counter on successful heartbeat.
|
||||
"""
|
||||
self._state.reconnect_count = 0
|
||||
logger.debug("ws_heartbeat_received")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Connection lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Establish the WebSocket connection.
|
||||
|
||||
.. todo::
|
||||
Implement a real WebSocket connection using the ``websockets``
|
||||
library. This requires a running server endpoint to connect
|
||||
to and should include TLS support, authentication header
|
||||
injection, and proper async lifecycle management.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Always — real WebSocket transport is
|
||||
not yet implemented.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"WebSocket transport is not yet implemented. "
|
||||
"A real connection using the `websockets` library is required "
|
||||
"before this client can be used in production. "
|
||||
"See: https://websockets.readthedocs.io/"
|
||||
)
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Close the WebSocket connection and stop reconnection."""
|
||||
self._running = False
|
||||
self._state.connected = False
|
||||
self._dedup.clear()
|
||||
logger.info("ws_disconnected", url=self._ws_url)
|
||||
|
||||
def reconnect(self) -> bool:
|
||||
"""Attempt to reconnect with exponential backoff.
|
||||
|
||||
Returns:
|
||||
``True`` if reconnection succeeded, ``False`` if
|
||||
max retries were exhausted.
|
||||
|
||||
Raises:
|
||||
ServerConnectionError: When max reconnection attempts
|
||||
are exhausted.
|
||||
"""
|
||||
if self._state.reconnect_count >= self._max_reconnects:
|
||||
self._state.connected = False
|
||||
raise ServerConnectionError(
|
||||
message=(
|
||||
f"Max reconnection attempts ({self._max_reconnects}) "
|
||||
f"exhausted for {self._ws_url}"
|
||||
),
|
||||
url=self._ws_url,
|
||||
)
|
||||
|
||||
delay = _ws_backoff_delay(
|
||||
self._state.reconnect_count,
|
||||
self._reconnect_base,
|
||||
self._reconnect_max,
|
||||
)
|
||||
logger.info(
|
||||
"ws_reconnecting",
|
||||
attempt=self._state.reconnect_count + 1,
|
||||
delay=delay,
|
||||
last_event_id=self._state.last_event_id,
|
||||
)
|
||||
time.sleep(delay)
|
||||
self._state.reconnect_count += 1
|
||||
self._state.connected = True
|
||||
return True
|
||||
|
||||
def negotiate_version(self, client_version: str = "1.0") -> str:
|
||||
"""Negotiate the event protocol version.
|
||||
|
||||
Args:
|
||||
client_version: The version this client supports.
|
||||
|
||||
Returns:
|
||||
The negotiated version (currently always returns the
|
||||
client version).
|
||||
"""
|
||||
self._state.negotiated_version = client_version
|
||||
logger.debug("ws_version_negotiated", version=client_version)
|
||||
return client_version
|
||||
|
||||
@property
|
||||
def state(self) -> ConnectionState:
|
||||
"""The current connection state."""
|
||||
return self._state
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ConnectionState",
|
||||
"EventCallback",
|
||||
"EventDeduplicator",
|
||||
"WebSocketClient",
|
||||
]
|
||||
@@ -564,6 +564,28 @@ class Settings(BaseSettings):
|
||||
)
|
||||
return self
|
||||
|
||||
# Server client configuration (M7 - server communication)
|
||||
server_base_url: str | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("CLEVERAGENTS_SERVER_BASE_URL"),
|
||||
description="Base URL of the remote CleverAgents server.",
|
||||
)
|
||||
server_api_token: str | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("CLEVERAGENTS_SERVER_API_TOKEN"),
|
||||
description="Bearer token for server authentication.",
|
||||
)
|
||||
server_tls_verify: bool = Field(
|
||||
default=True,
|
||||
validation_alias=AliasChoices("CLEVERAGENTS_SERVER_TLS_VERIFY"),
|
||||
description="Whether to verify TLS certificates for server connections.",
|
||||
)
|
||||
server_request_timeout: float = Field(
|
||||
default=30.0,
|
||||
gt=0,
|
||||
validation_alias=AliasChoices("CLEVERAGENTS_SERVER_REQUEST_TIMEOUT"),
|
||||
description="Per-request timeout in seconds for server communication.",
|
||||
)
|
||||
# Mock providers flag (M4 - provider fixes)
|
||||
mock_providers: bool = Field(
|
||||
default=False,
|
||||
|
||||
@@ -259,14 +259,14 @@ class SandboxStrategyRegistry:
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _validate_protocol(cls: type[Any]) -> None:
|
||||
def _validate_protocol(klass: type[Any]) -> None:
|
||||
"""Validate that a class satisfies :class:`SandboxStrategyProtocol`.
|
||||
|
||||
Uses structural subtyping: checks that all 9 required methods
|
||||
are present as callable attributes on the class.
|
||||
|
||||
Args:
|
||||
cls: The class to validate.
|
||||
klass: The class to validate.
|
||||
|
||||
Raises:
|
||||
ProtocolMismatchError: If the class is missing required methods.
|
||||
@@ -286,12 +286,12 @@ class SandboxStrategyRegistry:
|
||||
missing = [
|
||||
method
|
||||
for method in required_methods
|
||||
if not callable(getattr(cls, method, None))
|
||||
if not callable(getattr(klass, method, None))
|
||||
]
|
||||
|
||||
if missing:
|
||||
msg = (
|
||||
f"Class '{cls.__name__}' does not satisfy "
|
||||
f"Class '{klass.__name__}' does not satisfy "
|
||||
f"SandboxStrategyProtocol. Missing methods: "
|
||||
f"{', '.join(missing)}"
|
||||
)
|
||||
|
||||
@@ -1190,3 +1190,37 @@ detect_directory_languages # noqa: B018, F821
|
||||
get_servers_for_language # noqa: B018, F821
|
||||
restart_server # noqa: B018, F821
|
||||
_make_runtime_handler # noqa: B018, F821
|
||||
# Server HTTP client — Issue #335
|
||||
ServerHttpClient # noqa: B018, F821
|
||||
ServerConnectionError # noqa: B018, F821
|
||||
ServerTimeoutError # noqa: B018, F821
|
||||
ServerVersionMismatchError # noqa: B018, F821
|
||||
PageResult # noqa: B018, F821
|
||||
create_client_from_settings # noqa: B018, F821
|
||||
server_base_url # noqa: B018, F821
|
||||
server_api_token # noqa: B018, F821
|
||||
server_tls_verify # noqa: B018, F821
|
||||
server_request_timeout # noqa: B018, F821
|
||||
|
||||
# Plan sync client — Issue #336
|
||||
PlanSyncClient # noqa: B018, F821
|
||||
ConflictPolicy # noqa: B018, F821
|
||||
SyncScope # noqa: B018, F821
|
||||
SyncSummary # noqa: B018, F821
|
||||
ExecutionResult # noqa: B018, F821
|
||||
sync_all # noqa: B018, F821
|
||||
apply_plan # noqa: B018, F821
|
||||
get_plan_status # noqa: B018, F821
|
||||
|
||||
# WebSocket client — Issue #337
|
||||
WebSocketClient # noqa: B018, F821
|
||||
ConnectionState # noqa: B018, F821
|
||||
EventDeduplicator # noqa: B018, F821
|
||||
EventCallback # noqa: B018, F821
|
||||
|
||||
# Remote project client — Issue #338
|
||||
RemoteProjectClient # noqa: B018, F821
|
||||
RemoteProject # noqa: B018, F821
|
||||
resolve_project # noqa: B018, F821
|
||||
request_execution # noqa: B018, F821
|
||||
invalidate_cache # noqa: B018, F821
|
||||
|
||||
Reference in New Issue
Block a user