feat: switch to opencode
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user