Files
agentci/tests/test_opencode_abort.py
T
StanPonomarev ce9f1e3d20
Publish container image / Build and push (push) Successful in 32s
refactor tests
2026-07-26 23:49:40 +02:00

266 lines
7.9 KiB
Python

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