import asyncio import json from pathlib import Path import httpx import pytest from agentci.adapters.opencode import OpenCodeClient, OpenCodeError from agentci.domain.models 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) -> OpenCodeClient: selected_codegraph = codegraph or FakeCodeGraph() return OpenCodeClient( 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_ready_rejects_missing_provider(tmp_path: Path) -> None: async def handler(request: httpx.Request) -> httpx.Response: 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) return httpx.Response(200, json={**PROVIDERS, "connected": ["anthropic"]}) value = client(tmp_path, handler) assert not await value.ready() await value.close() async def test_starts_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, result = await value.start( 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_aborts_timed_out_session(tmp_path: Path) -> None: workspace = tmp_path / "workspace" workspace.mkdir() aborted = False async def handler(request: httpx.Request) -> httpx.Response: nonlocal aborted if request.url.path.endswith("/abort"): aborted = True return httpx.Response(200, json=True) raise httpx.ReadTimeout("slow", request=request) value = client(tmp_path, handler) with pytest.raises(OpenCodeError, match="exceeded 60 seconds"): 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 aborted await value.close() async def test_aborts_cancelled_session(tmp_path: Path) -> None: workspace = tmp_path / "workspace" workspace.mkdir() started = asyncio.Event() aborted = False async def handler(request: httpx.Request) -> httpx.Response: nonlocal aborted if request.url.path.endswith("/abort"): aborted = True return httpx.Response(200, json=True) started.set() await asyncio.Event().wait() raise AssertionError("unreachable") value = client(tmp_path, handler) turn = asyncio.create_task( value.resume( session_id="ses_existing", workspace=workspace, prompt="continue", model="openai/model", variant=None, schema_name="agent_result.json", result_type=AgentResult, ) ) await started.wait() turn.cancel() with pytest.raises(asyncio.CancelledError): await turn assert aborted await value.close()