Compare commits

...

6 Commits

Author SHA1 Message Date
freemo c9be72a99f fix: rename cls to klass in _validate_protocol staticmethod for pyright compliance
CI / benchmark-publish (pull_request) Has been skipped
CI / build (pull_request) Successful in 25s
CI / lint (pull_request) Successful in 3m32s
CI / typecheck (pull_request) Successful in 3m59s
CI / quality (pull_request) Successful in 4m1s
CI / security (pull_request) Successful in 4m8s
CI / unit_tests (pull_request) Successful in 6m34s
CI / integration_tests (pull_request) Successful in 8m8s
CI / docker (pull_request) Successful in 1m22s
CI / e2e_tests (pull_request) Successful in 10m45s
CI / coverage (pull_request) Successful in 10m4s
CI / status-check (pull_request) Successful in 1s
CI / benchmark-regression (pull_request) Successful in 54m59s
2026-03-24 20:28:19 +00:00
freemo 8e9aa7af48 fix(client): address server client chain review findings
- Promote _request() to public method request() in ServerHttpClient
  to fix encapsulation violation across sync_client and remote_project
- Fix WebSocket connect() to raise NotImplementedError with clear TODO
  documenting that real websockets transport is not yet implemented
- Fix thread safety: protect last_event_id write under self._lock
  in ws_client.process_event()
- Broaden exception handling in sync() to catch ServerTimeoutError
  and A2aNotAvailableError in addition to ServerConnectionError
- Add has_next to PageResult in benchmark and test helpers
- Add comments explaining Retry-After header logging-only behavior
  and why blocking time.sleep is acceptable in sync client
- Update all test steps, robot helpers, and benchmarks to use the
  renamed public request() method and updated connect() behavior

Refs: #335, #336, #337, #338
2026-03-24 20:28:19 +00:00
freemo 3fe7dabb65 feat(client): add remote project support
Implement RemoteProjectClient for accessing and executing projects
hosted on a remote CleverAgents server:
- Remote resource selection and server execution request wiring
- Project-name resolution for remote namespaces and server aliases
- Resolve project with fallback from custom to default namespace
- Remote project caching with configurable TTL (default 5 min)
- Explicit ResourceNotFoundError when remote project not found
- Cache invalidation per-namespace or global
- RemoteProject dataclass with project_id, name, namespace, alias,
  description
- Behave scenarios (16 scenarios, 54 steps)
- Robot Framework smoke tests
- ASV benchmark for request overhead baseline
- Reference documentation at docs/reference/server_remote_projects.md

ISSUES CLOSED: #338
2026-03-24 20:28:19 +00:00
freemo 61dc421975 feat(client): add websocket updates
Implement WebSocketClient for receiving real-time plan update events
from a CleverAgents server via WebSocket connection:
- Plan update subscription with reconnect/exponential backoff
- Event schema for plan status, progress, and log stream updates
- Heartbeat/ping handling that resets reconnect counter
- Resume from last event ID on reconnect
- Event version negotiation
- Event de-duplication by event_id via LRU-bounded EventDeduplicator
- Ordered delivery guarantees with thread-safe callback dispatch
- Configurable reconnect backoff parameters (base, max, max_reconnects)
- ConnectionState model tracking lifecycle state
- websockets library added to pyproject.toml dependencies
- Behave scenarios (16 scenarios, 62 steps)
- Robot Framework smoke tests
- ASV benchmark for message handling baseline
- Reference documentation at docs/reference/server_websocket.md

ISSUES CLOSED: #337
2026-03-24 20:28:19 +00:00
freemo ae1fd648d5 feat(client): add plan sync and remote execution
Implement PlanSyncClient for synchronizing local resources with a remote
CleverAgents server and submitting plans for remote execution:
- Sync actions, skills, tools, projects with configurable scope flags
- Conflict resolution policies (local_wins / server_wins)
- Remote plan execution, apply, and status query endpoints
- Server-side ID persistence in local item metadata
- Sync summary output (created/updated/skipped/errors)
- Dry-run mode that skips server mutations
- SyncScope, SyncSummary, ExecutionResult data models
- Behave scenarios (18 scenarios, 62 steps)
- Robot Framework smoke tests
- ASV benchmark for sync throughput baseline
- Reference documentation at docs/reference/server_sync.md

ISSUES CLOSED: #336
2026-03-24 20:28:19 +00:00
freemo 5f7bba3e96 feat(client): add server http client
Implement ServerHttpClient with httpx for server communication including:
- Health check endpoint (GET /health)
- Version negotiation (GET /version, POST /version/negotiate)
- Pagination helpers for list endpoints
- Per-request timeout and retry policy with exponential backoff
- Request/response logging with auth header redaction
- TLS verification toggle with warning when disabled
- Server error responses mapped to domain errors (A2aNotAvailableError, etc.)
- Client-specific exceptions (ServerConnectionError, ServerTimeoutError,
  ServerVersionMismatchError)
- Settings fields: server_base_url, server_api_token, server_tls_verify,
  server_request_timeout
- Factory function create_client_from_settings wired to Settings
- httpx added to pyproject.toml dependencies
- Behave scenarios (23 scenarios, 72 steps)
- Robot Framework smoke tests
- ASV benchmark for connection overhead baseline
- Reference documentation at docs/reference/server_client_http.md

ISSUES CLOSED: #335
2026-03-24 20:28:19 +00:00
34 changed files with 5359 additions and 4 deletions
+108
View File
@@ -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)
+101
View File
@@ -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",
)
+87
View File
@@ -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")
+86
View File
@@ -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()
+71
View File
@@ -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.
+55
View File
@@ -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 |
+67
View File
@@ -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).
+64
View File
@@ -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()
```
+131
View File
@@ -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"
+115
View File
@@ -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
+175
View File
@@ -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
+135
View File
@@ -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
+371
View File
@@ -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
+305
View File
@@ -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
+513
View File
@@ -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
+310
View File
@@ -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
+2
View File
@@ -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]
+140
View File
@@ -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()
+128
View File
@@ -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()
+184
View File
@@ -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()
+174
View File
@@ -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()
+81
View File
@@ -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
+65
View File
@@ -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
+81
View File
@@ -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
+81
View File
@@ -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
+9
View File
@@ -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] = []
+96
View File
@@ -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",
]
+522
View File
@@ -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",
]
+298
View File
@@ -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",
]
+380
View File
@@ -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",
]
+364
View File
@@ -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",
]
+22
View File
@@ -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)}"
)
+34
View File
@@ -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