from __future__ import annotations import gzip from pathlib import Path from typing import Any import httpx import pytest pytest.importorskip("fastapi.testclient") from fastapi import FastAPI from fastapi.testclient import TestClient from agency_swarm import run_fastapi from agency_swarm.integrations import openclaw as openclaw_mod from agency_swarm.integrations.openclaw import ( OpenClawIntegrationConfig, OpenClawRuntime, attach_openclaw_to_fastapi, normalize_openclaw_responses_request, ) from tests.integration.fastapi._openclaw_test_support import _build_openclaw_config def test_openclaw_config_manual_construction_defaults_to_full_tool_mode(tmp_path: Path) -> None: home_dir = tmp_path / "openclaw" config = OpenClawIntegrationConfig( autostart=False, host="127.0.0.1", port=18789, gateway_token="gateway-token", home_dir=home_dir, state_dir=home_dir / "state", config_path=home_dir / "openclaw.json", log_path=home_dir / "logs" / "openclaw-gateway.log", startup_timeout_seconds=5.0, proxy_timeout_seconds=30.0, default_model="openclaw:main", provider_model="openai/gpt-5.4-mini", gateway_command="openclaw gateway", ) assert config.tool_mode == "full" def test_openclaw_health_returns_runtime_snapshot(tmp_path: Path) -> None: runtime = OpenClawRuntime( OpenClawIntegrationConfig( autostart=False, host="127.0.0.1", port=18789, gateway_token="gateway-token", home_dir=tmp_path / "openclaw", state_dir=tmp_path / "openclaw" / "state", config_path=tmp_path / "openclaw" / "openclaw.json", log_path=tmp_path / "openclaw" / "logs" / "openclaw-gateway.log", startup_timeout_seconds=5.0, proxy_timeout_seconds=30.0, default_model="openclaw:main", provider_model="openai/gpt-5.4-mini", gateway_command="openclaw gateway", ) ) payload = runtime.health() assert payload["running"] is False assert payload["upstream_base_url"] == "http://127.0.0.1:18789" assert payload["home_dir"].endswith("openclaw") assert payload["state_dir"].endswith("openclaw/state") def test_openclaw_proxy_mount_paths_exist(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setattr("agency_swarm.integrations.openclaw._is_upstream_port_open", lambda _config: True) app = FastAPI() attach_openclaw_to_fastapi(app, _build_openclaw_config(tmp_path)) client = TestClient(app) health = client.get("/openclaw/health") assert health.status_code == 200 assert health.json()["upstream_base_url"] == "http://127.0.0.1:18789" responses = client.post("/openclaw/v1/responses", json={}) assert responses.status_code == 400 openapi = client.get("/openapi.json") assert openapi.status_code == 200 paths = openapi.json()["paths"] assert "/openclaw/v1/responses" in paths assert "/openclaw/health" in paths def test_openclaw_health_reports_unhealthy_when_upstream_is_down( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: monkeypatch.setattr("agency_swarm.integrations.openclaw._is_upstream_port_open", lambda _config: False) app = FastAPI() attach_openclaw_to_fastapi(app, _build_openclaw_config(tmp_path)) client = TestClient(app) health = client.get("/openclaw/health") assert health.status_code == 503 assert health.json() == {"ok": False, "upstream_base_url": "http://127.0.0.1:18789"} def test_openclaw_proxy_filters_request_keys_and_normalizes_payload( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: captured: dict[str, Any] = {} class _FakeAsyncClient: def __init__(self, *args: Any, **kwargs: Any) -> None: return None async def __aenter__(self) -> _FakeAsyncClient: return self async def __aexit__(self, exc_type, exc, tb) -> None: return None async def post(self, url: str, *, headers: dict[str, str], json: dict[str, Any]) -> httpx.Response: captured["url"] = url captured["headers"] = headers captured["json"] = json return httpx.Response( status_code=200, content=gzip.compress(b'{"ok": true}'), headers={ "content-type": "application/json", "retry-after": "3", "x-request-id": "req-non-stream", "content-encoding": "gzip", }, ) monkeypatch.setattr("agency_swarm.integrations.openclaw.httpx.AsyncClient", _FakeAsyncClient) app = FastAPI() attach_openclaw_to_fastapi(app, _build_openclaw_config(tmp_path)) client = TestClient(app) response = client.post( "/openclaw/v1/responses", json={ "model": "openclaw:main", "input": [{"role": "user", "content": [{"text": "hello"}]}], "stream": False, "tools": [ {"type": "function", "function": {"name": "calc", "parameters": {"type": "object"}}}, { "type": "function", "name": "summarize", "description": "Summarize text", "parameters": {"type": "object", "properties": {"text": {"type": "string"}}}, "strict": True, }, ], "tool_choice": {"type": "function", "name": "calc"}, "metadata": {"attempt": 1, "scope": {"a": 1}, "label": "ok"}, "include": ["response.output_text"], "parallel_tool_calls": True, }, ) assert response.status_code == 200 assert response.headers["retry-after"] == "3" assert response.headers["x-request-id"] == "req-non-stream" assert response.headers.get("content-encoding") is None forwarded = captured["json"] assert set(forwarded.keys()) <= { "model", "input", "instructions", "tools", "tool_choice", "stream", "max_output_tokens", "max_tool_calls", "user", "temperature", "top_p", "metadata", "store", "previous_response_id", "reasoning", "truncation", } assert "include" not in forwarded assert "parallel_tool_calls" not in forwarded assert forwarded["model"] == "openai/gpt-5.4-mini" assert forwarded["input"] == [ {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hello"}]} ] assert forwarded["tools"] == [ {"type": "function", "function": {"name": "calc", "parameters": {"type": "object"}}}, { "type": "function", "function": { "name": "summarize", "description": "Summarize text", "parameters": {"type": "object", "properties": {"text": {"type": "string"}}}, "strict": True, }, }, ] assert forwarded["tool_choice"] == {"type": "function", "function": {"name": "calc"}} assert forwarded["metadata"] == {"attempt": "1", "scope": '{"a": 1}', "label": "ok"} assert captured["headers"]["Authorization"] == "Bearer gateway-token" assert captured["url"] == "http://127.0.0.1:18789/v1/responses" def test_openclaw_proxy_forwards_encrypted_reasoning_include( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: captured: dict[str, Any] = {} class _FakeAsyncClient: def __init__(self, *args: Any, **kwargs: Any) -> None: return None async def __aenter__(self) -> _FakeAsyncClient: return self async def __aexit__(self, exc_type, exc, tb) -> None: return None async def post(self, url: str, *, headers: dict[str, str], json: dict[str, Any]) -> httpx.Response: captured["json"] = json return httpx.Response(status_code=200, json={"ok": True}) monkeypatch.setattr("agency_swarm.integrations.openclaw.httpx.AsyncClient", _FakeAsyncClient) app = FastAPI() attach_openclaw_to_fastapi(app, _build_openclaw_config(tmp_path)) client = TestClient(app) response = client.post( "/openclaw/v1/responses", json={ "model": "openclaw:main", "input": "hello", "include": ["response.output_text", "reasoning.encrypted_content"], "store": False, }, ) assert response.status_code == 200 assert captured["json"]["include"] == ["reasoning.encrypted_content"] assert captured["json"]["store"] is False def test_openclaw_proxy_preserves_full_history_without_synthesizing_session_fields( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: captured: dict[str, Any] = {} class _FakeAsyncClient: def __init__(self, *args: Any, **kwargs: Any) -> None: return None async def __aenter__(self) -> _FakeAsyncClient: return self async def __aexit__(self, exc_type, exc, tb) -> None: return None async def post(self, url: str, *, headers: dict[str, str], json: dict[str, Any]) -> httpx.Response: captured["json"] = json return httpx.Response(status_code=200, json={"ok": True}) monkeypatch.setattr("agency_swarm.integrations.openclaw.httpx.AsyncClient", _FakeAsyncClient) app = FastAPI() attach_openclaw_to_fastapi(app, _build_openclaw_config(tmp_path)) client = TestClient(app) response = client.post( "/openclaw/v1/responses", json={ "model": "openclaw:main", "input": [ {"role": "user", "content": [{"text": "Hi"}]}, {"role": "assistant", "content": [{"text": "Hello"}]}, {"role": "user", "content": [{"text": "Continue with the same chat"}]}, ], "stream": False, }, ) assert response.status_code == 200 forwarded = captured["json"] assert forwarded["input"] == [ {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "Hi"}]}, {"type": "message", "role": "assistant", "content": [{"type": "input_text", "text": "Hello"}]}, {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "Continue with the same chat"}]}, ] assert "user" not in forwarded assert "previous_response_id" not in forwarded def test_openclaw_proxy_rejects_unsupported_tool_types(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: called = {"upstream": False} class _FakeAsyncClient: def __init__(self, *args: Any, **kwargs: Any) -> None: return None async def __aenter__(self) -> _FakeAsyncClient: return self async def __aexit__(self, exc_type, exc, tb) -> None: return None async def post(self, url: str, *, headers: dict[str, str], json: dict[str, Any]) -> httpx.Response: called["upstream"] = True return httpx.Response(status_code=200, json={"ok": True}) monkeypatch.setattr("agency_swarm.integrations.openclaw.httpx.AsyncClient", _FakeAsyncClient) app = FastAPI() attach_openclaw_to_fastapi(app, _build_openclaw_config(tmp_path)) client = TestClient(app) response = client.post( "/openclaw/v1/responses", json={ "model": "openclaw:main", "input": "hello", "tools": [{"type": "web_search"}], }, ) assert response.status_code == 400 assert "not supported by OpenClaw" in response.json()["detail"] assert called["upstream"] is False def test_openclaw_proxy_rejects_non_list_tools(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: called = {"upstream": False} class _FakeAsyncClient: def __init__(self, *args: Any, **kwargs: Any) -> None: return None async def __aenter__(self) -> _FakeAsyncClient: return self async def __aexit__(self, exc_type, exc, tb) -> None: return None async def post(self, url: str, *, headers: dict[str, str], json: dict[str, Any]) -> httpx.Response: called["upstream"] = True return httpx.Response(status_code=200, json={"ok": True}) monkeypatch.setattr("agency_swarm.integrations.openclaw.httpx.AsyncClient", _FakeAsyncClient) app = FastAPI() attach_openclaw_to_fastapi(app, _build_openclaw_config(tmp_path)) client = TestClient(app) response = client.post( "/openclaw/v1/responses", json={ "model": "openclaw:main", "input": "hello", "tools": {"type": "function", "name": "calc"}, }, ) assert response.status_code == 400 assert "tools must be a list" in response.json()["detail"] assert called["upstream"] is False def test_cancel_endpoint_behavior_remains_on_existing_agency_route( agency_factory, tmp_path: Path, ) -> None: app = run_fastapi(agencies={"test_agency": agency_factory}, return_app=True, app_token_env="") assert app is not None attach_openclaw_to_fastapi(app, _build_openclaw_config(tmp_path)) client = TestClient(app) cancel = client.post("/test_agency/cancel_response_stream", json={"run_id": "missing-run"}) assert cancel.status_code == 404 assert "not found" in cancel.json()["detail"].lower() proxy_cancel = client.post("/openclaw/cancel_response_stream", json={"run_id": "missing-run"}) assert proxy_cancel.status_code == 404 def test_openclaw_proxy_uses_app_token_auth_when_attached_to_run_fastapi( monkeypatch: pytest.MonkeyPatch, agency_factory, tmp_path: Path, ) -> None: monkeypatch.setenv("APP_TOKEN", "secret-token") monkeypatch.setattr("agency_swarm.integrations.openclaw._is_upstream_port_open", lambda _config: True) class _FakeAsyncClient: def __init__(self, *args: Any, **kwargs: Any) -> None: return None async def __aenter__(self) -> _FakeAsyncClient: return self async def __aexit__(self, exc_type, exc, tb) -> None: return None async def post(self, url: str, *, headers: dict[str, str], json: dict[str, Any]) -> httpx.Response: return httpx.Response( status_code=200, content=b'{"ok": true}', headers={"content-type": "application/json"}, ) monkeypatch.setattr("agency_swarm.integrations.openclaw.httpx.AsyncClient", _FakeAsyncClient) app = run_fastapi(agencies={"secure": agency_factory}, return_app=True, app_token_env="APP_TOKEN") assert app is not None attach_openclaw_to_fastapi(app, _build_openclaw_config(tmp_path)) client = TestClient(app) unauthorized = client.post("/openclaw/v1/responses", json={"model": "openclaw:main", "input": "hello"}) assert unauthorized.status_code in (401, 403) health_unauthorized = client.get("/openclaw/health") assert health_unauthorized.status_code in (401, 403) authorized = client.post( "/openclaw/v1/responses", json={"model": "openclaw:main", "input": "hello"}, headers={"Authorization": "Bearer secret-token"}, ) assert authorized.status_code == 200 health_authorized = client.get("/openclaw/health", headers={"Authorization": "Bearer secret-token"}) assert health_authorized.status_code == 200 assert health_authorized.json() == {"ok": True, "upstream_base_url": "http://127.0.0.1:18789"} def test_openclaw_normalization_validation_error_paths() -> None: with pytest.raises(ValueError, match="model is required"): normalize_openclaw_responses_request({"input": "hello"}) with pytest.raises(ValueError, match="input is required"): normalize_openclaw_responses_request({"model": "openclaw:main"}) with pytest.raises(ValueError, match="input must be a string or list"): normalize_openclaw_responses_request({"model": "openclaw:main", "input": {"bad": "shape"}}) with pytest.raises(ValueError, match="input list items must be JSON objects"): normalize_openclaw_responses_request({"model": "openclaw:main", "input": ["bad"]}) with pytest.raises(ValueError, match="input message role must be a non-empty string"): normalize_openclaw_responses_request( {"model": "openclaw:main", "input": [{"type": "message", "content": "missing role"}]} ) with pytest.raises(ValueError, match="input message content must be a string or list"): normalize_openclaw_responses_request( {"model": "openclaw:main", "input": [{"role": "user", "content": {"bad": "shape"}}]} ) normalized = normalize_openclaw_responses_request( { "model": "openclaw:main", "input": "hello", "tool_choice": "unsupported", "metadata": "bad-metadata", "include": "reasoning.encrypted_content", } ) assert "tool_choice" not in normalized assert "metadata" not in normalized assert "include" not in normalized def test_openclaw_header_helpers() -> None: assert openclaw_mod._make_upstream_headers("") == {"Content-Type": "application/json"} assert openclaw_mod._make_upstream_headers("token") == { "Content-Type": "application/json", "Authorization": "Bearer token", } upstream = httpx.Response( status_code=200, content=gzip.compress(b"ok"), headers={ "content-type": "application/json", "x-request-id": "req-1", "content-encoding": "gzip", "content-length": "2", }, ) assert "content-encoding" in openclaw_mod._passthrough_response_headers(upstream, decoded_body=False) assert "content-encoding" not in openclaw_mod._passthrough_response_headers(upstream, decoded_body=True)