import asyncio import json from pathlib import Path import httpx import pytest from agentci.integrations.opencode.client import OpenCode, OpenCodeError from agentci.workflows.model import AgentResult API_DOCUMENT = { "paths": { "/global/health": {"get": {}}, "/provider": {"get": {}}, "/session": {"post": {}}, "/session/{sessionID}/message": {"post": {}}, "/session/{sessionID}/abort": {"post": {}}, } } PROVIDERS = { "connected": ["openai"], "all": [ { "id": "openai", "models": { "model": { "status": "active", "capabilities": {"toolcall": True}, "variants": {"high": {}}, } }, } ], } class FakeCodeGraph: def __init__(self) -> None: self.prepared: list[Path] = [] async def prepare(self, workspace: Path) -> None: self.prepared.append(workspace) def client(tmp_path: Path, handler, codegraph: FakeCodeGraph | None = None) -> OpenCode: selected_codegraph = codegraph or FakeCodeGraph() return OpenCode( base_url="http://opencode:4096", username="opencode", password="server-secret", schemas_dir=Path(__file__).parents[1] / "src" / "agentci" / "prompts" / "schemas", health_directory=tmp_path, required_models=(("openai/model", None),), timeout_seconds=60, codegraph=selected_codegraph, # type: ignore[arg-type] transport=httpx.MockTransport(handler), ) async def test_ready_requires_healthy_server_and_connected_provider(tmp_path: Path) -> None: expected_directory = str(tmp_path) async def handler(request: httpx.Request) -> httpx.Response: assert request.headers["authorization"].startswith("Basic ") if request.url.path == "/global/health": return httpx.Response(200, json={"healthy": True, "version": "1.18.4"}) if request.url.path == "/doc": return httpx.Response(200, json=API_DOCUMENT) assert request.headers["x-opencode-directory"] == expected_directory return httpx.Response(200, json=PROVIDERS) value = client(tmp_path, handler) assert await value.ready() await value.close() async def test_concurrent_readiness_checks_share_one_probe(tmp_path: Path) -> None: health_started = asyncio.Event() release_health = asyncio.Event() paths: list[str] = [] async def handler(request: httpx.Request) -> httpx.Response: paths.append(request.url.path) if request.url.path == "/global/health": health_started.set() await release_health.wait() return httpx.Response(200, json={"healthy": True, "version": "1.18.4"}) if request.url.path == "/doc": return httpx.Response(200, json=API_DOCUMENT) return httpx.Response(200, json=PROVIDERS) value = client(tmp_path, handler) first = asyncio.create_task(value.ready()) await health_started.wait() second = asyncio.create_task(value.ready()) release_health.set() assert await asyncio.gather(first, second) == [True, True] assert paths == ["/global/health", "/doc", "/provider"] await value.close() @pytest.mark.parametrize( ("health", "document", "providers", "expected_paths"), [ ( {"healthy": False, "version": "1.18.4"}, API_DOCUMENT, PROVIDERS, ["/global/health"], ), ( {"healthy": True, "version": "2.0.0"}, API_DOCUMENT, PROVIDERS, ["/global/health"], ), ( {"healthy": True, "version": "1.18.4"}, {"paths": {}}, PROVIDERS, ["/global/health", "/doc"], ), ( {"healthy": True, "version": "1.18.4"}, API_DOCUMENT, { "connected": ["openai"], "all": [ { "id": "openai", "models": { "model": { "status": "active", "capabilities": {"toolcall": False}, "variants": {"high": {}}, } }, } ], }, ["/global/health", "/doc", "/provider"], ), ], ) async def test_ready_rejects_incomplete_runtime_matrix( tmp_path: Path, health: object, document: object, providers: object, expected_paths: list[str], ) -> None: paths: list[str] = [] async def handler(request: httpx.Request) -> httpx.Response: paths.append(request.url.path) if request.url.path == "/global/health": return httpx.Response(200, json=health) if request.url.path == "/doc": return httpx.Response(200, json=document) return httpx.Response(200, json=providers) value = client(tmp_path, handler) assert not await value.ready() assert paths == expected_paths await value.close() @pytest.mark.parametrize("response_kind", ["http_error", "invalid_json"]) async def test_ready_converts_probe_errors_to_not_ready(tmp_path: Path, response_kind: str) -> None: async def handler(_request: httpx.Request) -> httpx.Response: if response_kind == "http_error": return httpx.Response(503) return httpx.Response(200, content=b"not-json") value = client(tmp_path, handler) assert not await value.ready() await value.close() @pytest.mark.parametrize( ("response_kind", "message"), [ ("invalid_json", "OpenCode request failed: POST /session"), ("array", "OpenCode returned an invalid response for POST /session"), ("http_error", "OpenCode request failed: POST /session: gateway detail"), ], ) async def test_create_session_reports_malformed_and_error_responses( tmp_path: Path, response_kind: str, message: str ) -> None: async def handler(_request: httpx.Request) -> httpx.Response: if response_kind == "invalid_json": return httpx.Response(200, content=b"not-json") if response_kind == "array": return httpx.Response(200, json=[]) return httpx.Response(502, text="gateway detail") value = client(tmp_path, handler) with pytest.raises(OpenCodeError, match=message): await value.create_session(tmp_path, "agent_result.json") await value.close() @pytest.mark.parametrize("payload", [{}, {"id": ""}, {"id": 42}]) async def test_create_session_requires_nonempty_string_id( tmp_path: Path, payload: dict[str, object] ) -> None: async def handler(_request: httpx.Request) -> httpx.Response: return httpx.Response(200, json=payload) value = client(tmp_path, handler) with pytest.raises(OpenCodeError, match="did not return a session ID"): await value.create_session(tmp_path, "agent_result.json") await value.close() async def test_creates_and_resumes_structured_session_in_workspace(tmp_path: Path) -> None: workspace = tmp_path / "workspace" workspace.mkdir() codegraph = FakeCodeGraph() requests: list[httpx.Request] = [] async def handler(request: httpx.Request) -> httpx.Response: requests.append(request) assert request.headers["x-opencode-directory"] == str(workspace.resolve()) if request.url.path == "/session": return httpx.Response(200, json={"id": "ses_new"}) body = json.loads(request.content) assert body["model"] == {"providerID": "openai", "modelID": "model"} assert body["agent"] == "build" assert body["variant"] == "high" assert body["format"]["type"] == "json_schema" assert body["format"]["schema"]["required"] == ["summary_markdown", "tests"] return httpx.Response( 200, json={ "info": { "role": "assistant", "sessionID": "ses_new", "structured": {"summary_markdown": "summary", "tests": []}, }, "parts": [], }, ) value = client(tmp_path, handler, codegraph) session_id = await value.create_session(workspace, "agent_result.json") result = await value.resume( session_id=session_id, workspace=workspace, prompt="implement", model="openai/model", variant="high", schema_name="agent_result.json", result_type=AgentResult, ) assert session_id == "ses_new" assert result.summary_markdown == "summary" assert codegraph.prepared == [workspace] assert [request.url.path for request in requests] == [ "/session", "/session/ses_new/message", ] await value.close() async def test_retries_invalid_structured_result_on_same_session(tmp_path: Path) -> None: workspace = tmp_path / "workspace" workspace.mkdir() prompts: list[str] = [] async def handler(request: httpx.Request) -> httpx.Response: body = json.loads(request.content) prompts.append(body["parts"][0]["text"]) if len(prompts) == 1: return httpx.Response(200, json={"info": {"error": {"name": "StructuredOutputError"}}}) return httpx.Response( 200, json={ "info": {"structured": {"summary_markdown": "fixed", "tests": []}}, "parts": [], }, ) value = client(tmp_path, handler) result = await value.resume( session_id="ses_existing", workspace=workspace, prompt="continue", model="openai/model", variant=None, schema_name="agent_result.json", result_type=AgentResult, ) assert result.summary_markdown == "fixed" assert prompts[0] == "continue" assert "without repeating repository work" in prompts[1] await value.close() async def test_structured_validation_retry_exhaustion_reports_final_error( tmp_path: Path, ) -> None: workspace = tmp_path / "workspace" workspace.mkdir() prompts: list[str] = [] paths: list[str] = [] async def handler(request: httpx.Request) -> httpx.Response: paths.append(request.url.path) if request.url.path.endswith("/abort"): return httpx.Response(200, json=True) body = json.loads(request.content) prompts.append(body["parts"][0]["text"]) return httpx.Response( 200, json={ "info": {"structured": {"summary_markdown": "", "tests": "invalid"}}, "parts": [], }, ) value = client(tmp_path, handler) with pytest.raises( OpenCodeError, match="structured result failed validation", ): await value.resume( session_id="ses_existing", workspace=workspace, prompt="continue", model="openai/model", variant=None, schema_name="agent_result.json", result_type=AgentResult, ) assert len(prompts) == 2 assert prompts[0] == "continue" assert "without repeating repository work" in prompts[1] assert paths[-1] == "/session/ses_existing/abort" await value.close()