rewrite phase 1
This commit is contained in:
+294
-15
@@ -5,8 +5,8 @@ from pathlib import Path
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from agentci.adapters.opencode import OpenCodeClient, OpenCodeError
|
||||
from agentci.domain.models import AgentResult
|
||||
from agentci.opencode import OpenCode, OpenCodeError
|
||||
from agentci.workflows.model import AgentResult
|
||||
|
||||
API_DOCUMENT = {
|
||||
"paths": {
|
||||
@@ -42,9 +42,9 @@ class FakeCodeGraph:
|
||||
self.prepared.append(workspace)
|
||||
|
||||
|
||||
def client(tmp_path: Path, handler, codegraph: FakeCodeGraph | None = None) -> OpenCodeClient:
|
||||
def client(tmp_path: Path, handler, codegraph: FakeCodeGraph | None = None) -> OpenCode:
|
||||
selected_codegraph = codegraph or FakeCodeGraph()
|
||||
return OpenCodeClient(
|
||||
return OpenCode(
|
||||
base_url="http://opencode:4096",
|
||||
username="opencode",
|
||||
password="server-secret",
|
||||
@@ -74,20 +74,154 @@ async def test_ready_requires_healthy_server_and_connected_provider(tmp_path: Pa
|
||||
await value.close()
|
||||
|
||||
|
||||
async def test_ready_rejects_missing_provider(tmp_path: Path) -> None:
|
||||
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, "connected": ["anthropic"]})
|
||||
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,
|
||||
{**PROVIDERS, "connected": ["anthropic"]},
|
||||
["/global/health", "/doc", "/provider"],
|
||||
),
|
||||
(
|
||||
{"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()
|
||||
|
||||
|
||||
async def test_starts_structured_session_in_workspace(tmp_path: Path) -> None:
|
||||
@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()
|
||||
@@ -117,7 +251,9 @@ async def test_starts_structured_session_in_workspace(tmp_path: Path) -> None:
|
||||
)
|
||||
|
||||
value = client(tmp_path, handler, codegraph)
|
||||
session_id, result = await value.start(
|
||||
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",
|
||||
@@ -145,9 +281,7 @@ async def test_retries_invalid_structured_result_on_same_session(tmp_path: Path)
|
||||
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": {"error": {"name": "StructuredOutputError"}}})
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
@@ -173,15 +307,160 @@ async def test_retries_invalid_structured_result_on_same_session(tmp_path: Path)
|
||||
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()
|
||||
|
||||
|
||||
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_close_aborts_an_active_session(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
message_started = asyncio.Event()
|
||||
release_message = asyncio.Event()
|
||||
paths: list[str] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
paths.append(request.url.path)
|
||||
if request.url.path.endswith("/abort"):
|
||||
assert request.headers["x-opencode-directory"] == str(workspace.resolve())
|
||||
return httpx.Response(200, json=True)
|
||||
message_started.set()
|
||||
await release_message.wait()
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"info": {"structured": {"summary_markdown": "finished", "tests": []}},
|
||||
"parts": [],
|
||||
},
|
||||
)
|
||||
|
||||
value = client(tmp_path, handler)
|
||||
turn = asyncio.create_task(
|
||||
value.resume(
|
||||
session_id="ses_active",
|
||||
workspace=workspace,
|
||||
prompt="continue",
|
||||
model="openai/model",
|
||||
variant=None,
|
||||
schema_name="agent_result.json",
|
||||
result_type=AgentResult,
|
||||
)
|
||||
)
|
||||
await message_started.wait()
|
||||
|
||||
await value.close()
|
||||
release_message.set()
|
||||
result = await turn
|
||||
|
||||
assert result.summary_markdown == "finished"
|
||||
assert paths == ["/session/ses_active/message", "/session/ses_active/abort"]
|
||||
assert value.client.is_closed
|
||||
|
||||
|
||||
async def test_close_continues_after_active_session_abort_failure(tmp_path: Path) -> None:
|
||||
paths: list[str] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
paths.append(request.url.path)
|
||||
if "/failed/" in request.url.path:
|
||||
return httpx.Response(503)
|
||||
return httpx.Response(200, json=True)
|
||||
|
||||
value = client(tmp_path, handler)
|
||||
value._active_sessions.update(
|
||||
{
|
||||
"failed": tmp_path / "first",
|
||||
"succeeds": tmp_path / "second",
|
||||
}
|
||||
)
|
||||
|
||||
await value.close()
|
||||
|
||||
assert paths == ["/session/failed/abort", "/session/succeeds/abort"]
|
||||
assert value.client.is_closed
|
||||
|
||||
|
||||
async def test_aborts_timed_out_session(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
aborted = False
|
||||
abort_count = 0
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal aborted
|
||||
nonlocal abort_count
|
||||
if request.url.path.endswith("/abort"):
|
||||
aborted = True
|
||||
abort_count += 1
|
||||
return httpx.Response(200, json=True)
|
||||
raise httpx.ReadTimeout("slow", request=request)
|
||||
|
||||
@@ -197,7 +476,7 @@ async def test_aborts_timed_out_session(tmp_path: Path) -> None:
|
||||
result_type=AgentResult,
|
||||
)
|
||||
|
||||
assert aborted
|
||||
assert abort_count == 1
|
||||
await value.close()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user