import asyncio from pathlib import Path from typing import Any import httpx import pytest from agentci.integrations.opencode.client import OpenCode, OpenCodeError from agentci.workflows.model import AgentResult class FakeCodeGraph: async def prepare(self, _workspace: Path) -> None: pass def client( tmp_path: Path, handler: Any, *, codegraph: FakeCodeGraph | None = None, timeout_seconds: int = 60, ) -> OpenCode: selected_codegraph = FakeCodeGraph() if codegraph is None else codegraph return OpenCode( base_url="http://opencode:4096", username="opencode", password="secret", schemas_dir=Path(__file__).parents[1] / "src" / "agentci" / "prompts" / "schemas", health_directory=tmp_path, required_models=(), timeout_seconds=timeout_seconds, codegraph=selected_codegraph, # type: ignore[arg-type] transport=httpx.MockTransport(handler), ) @pytest.mark.parametrize( ("status", "succeeds"), [ (200, True), (204, True), (404, True), (409, True), (400, False), (429, False), (500, False), ], ) async def test_abort_status_contract( tmp_path: Path, status: int, succeeds: bool, ) -> None: value = client(tmp_path, lambda _request: httpx.Response(status)) if succeeds: await value.abort("session", tmp_path) else: with pytest.raises(OpenCodeError): await value.abort("session", tmp_path) await value.close() async def test_abort_sends_authenticated_workspace_request(tmp_path: Path) -> None: requests: list[httpx.Request] = [] async def handler(request: httpx.Request) -> httpx.Response: requests.append(request) return httpx.Response(204) value = client(tmp_path, handler) await value.abort("ses_123", tmp_path) await value.close() assert len(requests) == 1 request = requests[0] assert request.method == "POST" assert request.url == httpx.URL("http://opencode:4096/session/ses_123/abort") assert request.headers["authorization"].startswith("Basic ") assert request.headers["x-opencode-directory"] == str(tmp_path) async def test_best_effort_abort_suppresses_transport_failure(tmp_path: Path) -> None: async def handler(request: httpx.Request) -> httpx.Response: raise httpx.ConnectError("connection refused", request=request) value = client(tmp_path, handler) await value.abort("session", tmp_path, best_effort=True) await value.close() async def test_cleanup_failure_does_not_mask_turn_error(tmp_path: Path) -> None: workspace = tmp_path / "workspace" workspace.mkdir() 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(500, text="abort failed") return httpx.Response(200, json={"unexpected": True}) value = client(tmp_path, handler) with pytest.raises( OpenCodeError, match="OpenCode response did not include assistant metadata", ): 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 paths == [ "/session/ses_existing/message", "/session/ses_existing/abort", ] await value.close() async def test_aborts_timed_out_session(tmp_path: Path) -> None: workspace = tmp_path / "workspace" workspace.mkdir() abort_count = 0 async def handler(request: httpx.Request) -> httpx.Response: nonlocal abort_count if request.url.path.endswith("/abort"): abort_count += 1 return httpx.Response(200, json=True) raise httpx.ReadTimeout("slow", request=request) value = client(tmp_path, handler, timeout_seconds=60) 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 abort_count == 1 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() async def test_close_aborts_all_active_sessions_and_continues_after_failure( tmp_path: Path, ) -> None: failed_workspace = tmp_path / "failed-workspace" succeeds_workspace = tmp_path / "succeeds-workspace" failed_workspace.mkdir() succeeds_workspace.mkdir() failed_message_started = asyncio.Event() succeeds_message_started = asyncio.Event() release_messages = asyncio.Event() abort_requests: list[httpx.Request] = [] async def handler(request: httpx.Request) -> httpx.Response: path = request.url.path if path.endswith("/abort"): abort_requests.append(request) return httpx.Response(503 if "/failed/" in path else 204) if "/failed/" in path: failed_message_started.set() summary = "failed-session-finished" else: succeeds_message_started.set() summary = "succeeds-session-finished" await release_messages.wait() return httpx.Response( 200, json={ "info": {"structured": {"summary_markdown": summary, "tests": []}}, "parts": [], }, ) value = client(tmp_path, handler) failed_turn = asyncio.create_task( value.resume( session_id="failed", workspace=failed_workspace, prompt="continue", model="openai/model", variant=None, schema_name="agent_result.json", result_type=AgentResult, ) ) await failed_message_started.wait() succeeds_turn = asyncio.create_task( value.resume( session_id="succeeds", workspace=succeeds_workspace, prompt="continue", model="openai/model", variant=None, schema_name="agent_result.json", result_type=AgentResult, ) ) await succeeds_message_started.wait() await value.close() release_messages.set() completed = await asyncio.gather(failed_turn, succeeds_turn) assert [request.url for request in abort_requests] == [ httpx.URL("http://opencode:4096/session/failed/abort"), httpx.URL("http://opencode:4096/session/succeeds/abort"), ] assert [request.headers["x-opencode-directory"] for request in abort_requests] == [ str(failed_workspace.resolve()), str(succeeds_workspace.resolve()), ] assert [result.summary_markdown for result in completed] == [ "failed-session-finished", "succeeds-session-finished", ] assert value.client.is_closed