This commit is contained in:
+219
-35
@@ -1,36 +1,63 @@
|
||||
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
|
||||
|
||||
|
||||
def client(tmp_path: Path, status: int) -> OpenCode:
|
||||
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=tmp_path,
|
||||
schemas_dir=Path(__file__).parents[1] / "src" / "agentci" / "prompts" / "schemas",
|
||||
health_directory=tmp_path,
|
||||
required_models=(),
|
||||
timeout_seconds=60,
|
||||
transport=httpx.MockTransport(lambda _request: httpx.Response(status)),
|
||||
timeout_seconds=timeout_seconds,
|
||||
codegraph=selected_codegraph, # type: ignore[arg-type]
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [200, 204, 404, 409])
|
||||
async def test_absent_or_inactive_session_is_success(tmp_path: Path, status: int) -> None:
|
||||
value = client(tmp_path, status)
|
||||
await value.abort("session", tmp_path)
|
||||
await value.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [400, 429, 500])
|
||||
async def test_abort_failure_is_visible_for_retry(tmp_path: Path, status: int) -> None:
|
||||
value = client(tmp_path, status)
|
||||
with pytest.raises(OpenCodeError):
|
||||
@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()
|
||||
|
||||
|
||||
@@ -41,16 +68,7 @@ async def test_abort_sends_authenticated_workspace_request(tmp_path: Path) -> No
|
||||
requests.append(request)
|
||||
return httpx.Response(204)
|
||||
|
||||
value = OpenCode(
|
||||
base_url="http://opencode:4096/",
|
||||
username="opencode",
|
||||
password="secret",
|
||||
schemas_dir=tmp_path,
|
||||
health_directory=tmp_path,
|
||||
required_models=(),
|
||||
timeout_seconds=60,
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
value = client(tmp_path, handler)
|
||||
await value.abort("ses_123", tmp_path)
|
||||
await value.close()
|
||||
|
||||
@@ -66,16 +84,182 @@ async def test_best_effort_abort_suppresses_transport_failure(tmp_path: Path) ->
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectError("connection refused", request=request)
|
||||
|
||||
value = OpenCode(
|
||||
base_url="http://opencode:4096",
|
||||
username="opencode",
|
||||
password="secret",
|
||||
schemas_dir=tmp_path,
|
||||
health_directory=tmp_path,
|
||||
required_models=(),
|
||||
timeout_seconds=60,
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user