rewrite phase 1
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
import agentci.app as app_module
|
||||
|
||||
|
||||
class BlockingWorker:
|
||||
def __init__(self, events: list[str]) -> None:
|
||||
self.events = events
|
||||
self.started = asyncio.Event()
|
||||
|
||||
async def run(self, stop: asyncio.Event) -> None:
|
||||
self.events.append("worker-started")
|
||||
self.started.set()
|
||||
try:
|
||||
await stop.wait()
|
||||
except asyncio.CancelledError:
|
||||
self.events.append(f"worker-cancelled:{stop.is_set()}")
|
||||
raise
|
||||
|
||||
|
||||
class FailingWorker:
|
||||
def __init__(self) -> None:
|
||||
self.started = asyncio.Event()
|
||||
|
||||
async def run(self, _stop: asyncio.Event) -> None:
|
||||
self.started.set()
|
||||
raise RuntimeError("worker failed")
|
||||
|
||||
|
||||
class FakeRuntime:
|
||||
def __init__(self, worker: object, events: list[str]) -> None:
|
||||
self.worker = worker
|
||||
self.events = events
|
||||
self.closed = False
|
||||
|
||||
async def close(self) -> None:
|
||||
self.events.append("runtime-closed")
|
||||
self.closed = True
|
||||
|
||||
|
||||
async def test_lifespan_starts_worker_cancels_it_and_closes_runtime(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
events: list[str] = []
|
||||
worker = BlockingWorker(events)
|
||||
runtime = FakeRuntime(worker, events)
|
||||
selected_settings = SimpleNamespace(name="selected")
|
||||
built_with: list[object] = []
|
||||
configured: list[bool] = []
|
||||
|
||||
async def build(settings: object) -> FakeRuntime:
|
||||
built_with.append(settings)
|
||||
return runtime
|
||||
|
||||
monkeypatch.setattr(app_module, "build_runtime", build)
|
||||
monkeypatch.setattr(app_module, "configure_logging", lambda: configured.append(True))
|
||||
application = app_module.create_app(selected_settings) # type: ignore[arg-type]
|
||||
|
||||
async with application.router.lifespan_context(application):
|
||||
await worker.started.wait()
|
||||
assert application.state.runtime is runtime
|
||||
assert not runtime.closed
|
||||
|
||||
assert built_with == [selected_settings]
|
||||
assert configured == [True]
|
||||
assert events == ["worker-started", "worker-cancelled:True", "runtime-closed"]
|
||||
|
||||
|
||||
async def test_lifespan_closes_runtime_when_worker_task_fails(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
events: list[str] = []
|
||||
worker = FailingWorker()
|
||||
runtime = FakeRuntime(worker, events)
|
||||
|
||||
async def build(_settings: object) -> FakeRuntime:
|
||||
return runtime
|
||||
|
||||
monkeypatch.setattr(app_module, "build_runtime", build)
|
||||
monkeypatch.setattr(app_module, "configure_logging", lambda: None)
|
||||
application = app_module.create_app(SimpleNamespace()) # type: ignore[arg-type]
|
||||
|
||||
with pytest.raises(RuntimeError, match="worker failed"):
|
||||
async with application.router.lifespan_context(application):
|
||||
await worker.started.wait()
|
||||
|
||||
assert runtime.closed
|
||||
assert events == ["runtime-closed"]
|
||||
|
||||
|
||||
async def test_lifespan_propagates_runtime_startup_failure_without_starting_worker(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
configured: list[bool] = []
|
||||
|
||||
async def fail_build(_settings: object) -> None:
|
||||
raise RuntimeError("database unavailable")
|
||||
|
||||
monkeypatch.setattr(app_module, "build_runtime", fail_build)
|
||||
monkeypatch.setattr(app_module, "configure_logging", lambda: configured.append(True))
|
||||
application = app_module.create_app(SimpleNamespace()) # type: ignore[arg-type]
|
||||
|
||||
with pytest.raises(RuntimeError, match="database unavailable"):
|
||||
async with application.router.lifespan_context(application):
|
||||
pytest.fail("startup failure must prevent serving requests")
|
||||
|
||||
assert configured == [True]
|
||||
assert not hasattr(application.state, "runtime")
|
||||
|
||||
|
||||
async def test_global_exception_handler_returns_safe_json() -> None:
|
||||
application = app_module.create_app(SimpleNamespace()) # type: ignore[arg-type]
|
||||
|
||||
@application.get("/explode")
|
||||
async def explode() -> None:
|
||||
raise RuntimeError("sensitive provider detail")
|
||||
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=application, raise_app_exceptions=False),
|
||||
base_url="http://test",
|
||||
) as client:
|
||||
response = await client.get("/explode")
|
||||
|
||||
assert response.status_code == 500
|
||||
assert response.json() == {"detail": "Internal server error. See service logs for diagnostics."}
|
||||
+348
-89
@@ -1,22 +1,30 @@
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
|
||||
from agentci.domain.models import (
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agentci.engine.model import Workflow, WorkflowKind
|
||||
from agentci.engine.run import JobRun
|
||||
from agentci.workflows.model import (
|
||||
AgentResult,
|
||||
Job,
|
||||
JobKind,
|
||||
PlanArtifact,
|
||||
ReviewFinding,
|
||||
ReviewReport,
|
||||
ReviewSeverity,
|
||||
Workflow,
|
||||
WorkflowKind,
|
||||
)
|
||||
from agentci.workflows.code_review import CodeReviewLoop
|
||||
from agentci.workflows.review import (
|
||||
review_implementation_loop,
|
||||
review_implementation_once,
|
||||
review_plan_loop,
|
||||
review_plan_once,
|
||||
)
|
||||
from agentci.workflows.services import WorkflowServices
|
||||
|
||||
|
||||
def serious_report() -> ReviewReport:
|
||||
def serious_report(summary: str = "Needs work") -> ReviewReport:
|
||||
return ReviewReport(
|
||||
summary="Needs work",
|
||||
summary=summary,
|
||||
findings=[
|
||||
ReviewFinding(
|
||||
severity=ReviewSeverity.MAJOR,
|
||||
@@ -28,101 +36,352 @@ def serious_report() -> ReviewReport:
|
||||
)
|
||||
|
||||
|
||||
class FakeOpenCode:
|
||||
def __init__(self, reports: list[ReviewReport]) -> None:
|
||||
self.reports = iter(reports)
|
||||
self.reviews = 0
|
||||
self.revisions = 0
|
||||
|
||||
async def create_session(self, *_args):
|
||||
return "reviewer"
|
||||
|
||||
async def resume(self, **kwargs):
|
||||
if kwargs["result_type"] is ReviewReport:
|
||||
self.reviews += 1
|
||||
return next(self.reports)
|
||||
self.revisions += 1
|
||||
return AgentResult(summary_markdown=f"revision {self.revisions}", tests=[])
|
||||
def clean_report() -> ReviewReport:
|
||||
return ReviewReport(summary="Ready", findings=[])
|
||||
|
||||
|
||||
class FakeStorage:
|
||||
async def update_job(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
async def update_workflow(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
|
||||
class FakePrompts:
|
||||
def render(self, name, **_kwargs):
|
||||
return name
|
||||
|
||||
|
||||
def objects(rounds: int, reports: list[ReviewReport]):
|
||||
opencode = FakeOpenCode(reports)
|
||||
settings = SimpleNamespace(
|
||||
implement_review_rounds=rounds,
|
||||
implement_model="model",
|
||||
implement_variant="high",
|
||||
def minor_report() -> ReviewReport:
|
||||
return ReviewReport(
|
||||
summary="Optional improvement",
|
||||
findings=[
|
||||
ReviewFinding(
|
||||
severity=ReviewSeverity.MINOR,
|
||||
title="Clarify wording",
|
||||
detail="The wording could be clearer.",
|
||||
recommendation="Tighten it when convenient.",
|
||||
)
|
||||
],
|
||||
)
|
||||
deps = SimpleNamespace(
|
||||
settings=settings,
|
||||
opencode=opencode,
|
||||
storage=FakeStorage(),
|
||||
prompts=FakePrompts(),
|
||||
development=SimpleNamespace(description="python"),
|
||||
)
|
||||
workflow = Workflow(
|
||||
|
||||
|
||||
class RecordingOpenCode:
|
||||
def __init__(self, responses: list[BaseModel]) -> None:
|
||||
self.responses = list(responses)
|
||||
self.created_sessions: list[tuple[Path, str]] = []
|
||||
self.resume_calls: list[dict[str, Any]] = []
|
||||
|
||||
async def create_session(self, workspace: Path, title: str) -> str:
|
||||
self.created_sessions.append((workspace, title))
|
||||
return f"{title}-session"
|
||||
|
||||
async def resume(self, **values: Any) -> BaseModel:
|
||||
self.resume_calls.append(values)
|
||||
response = self.responses.pop(0)
|
||||
assert isinstance(response, values["result_type"])
|
||||
return response
|
||||
|
||||
|
||||
class RecordingRepository:
|
||||
def __init__(self) -> None:
|
||||
self.saved_workflows: list[Workflow] = []
|
||||
|
||||
async def save_workflow(self, workflow: Workflow) -> None:
|
||||
self.saved_workflows.append(workflow)
|
||||
|
||||
|
||||
class RecordingRun(JobRun):
|
||||
def __init__(self) -> None:
|
||||
self.stages: list[str] = []
|
||||
|
||||
async def stage(self, stage: str) -> None:
|
||||
self.stages.append(stage)
|
||||
|
||||
|
||||
class RecordingPrompts:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, dict[str, str]]] = []
|
||||
|
||||
def render(self, name: str, **values: str) -> str:
|
||||
self.calls.append((name, values))
|
||||
return f"rendered {name}"
|
||||
|
||||
|
||||
def workflow(*, reviewer_session_id: str | None = None) -> Workflow:
|
||||
return Workflow(
|
||||
id="flow",
|
||||
kind=WorkflowKind.IMPLEMENT,
|
||||
repo_owner="org",
|
||||
repo_name="repo",
|
||||
issue_number=1,
|
||||
workspace_path=Path("."),
|
||||
workspace_path=Path("/workspace/repo"),
|
||||
base_sha="abc",
|
||||
primary_session_id="primary",
|
||||
primary_session_id="primary-session",
|
||||
reviewer_session_id=reviewer_session_id,
|
||||
)
|
||||
job = Job(
|
||||
id="job",
|
||||
kind=JobKind.IMPLEMENT,
|
||||
target_key="target",
|
||||
repo_owner="org",
|
||||
repo_name="repo",
|
||||
issue_number=1,
|
||||
pr_number=None,
|
||||
requester="alice",
|
||||
message="",
|
||||
comment_id=1,
|
||||
)
|
||||
return CodeReviewLoop(deps), opencode, workflow, job # type: ignore[arg-type]
|
||||
|
||||
|
||||
async def test_stops_after_clean_second_review() -> None:
|
||||
clean = ReviewReport(summary="Ready", findings=[])
|
||||
loop, opencode, workflow, job = objects(4, [serious_report(), clean])
|
||||
_, report = await loop.run(
|
||||
job,
|
||||
workflow,
|
||||
def objects(
|
||||
responses: list[BaseModel],
|
||||
*,
|
||||
plan_rounds: int = 4,
|
||||
implementation_rounds: int = 3,
|
||||
) -> tuple[
|
||||
RecordingOpenCode,
|
||||
RecordingRepository,
|
||||
RecordingPrompts,
|
||||
WorkflowServices,
|
||||
]:
|
||||
opencode = RecordingOpenCode(responses)
|
||||
repository = RecordingRepository()
|
||||
prompts = RecordingPrompts()
|
||||
services = cast(
|
||||
WorkflowServices,
|
||||
SimpleNamespace(
|
||||
settings=SimpleNamespace(
|
||||
plan_review_rounds=plan_rounds,
|
||||
plan_model="provider/plan",
|
||||
plan_variant="high",
|
||||
implement_review_rounds=implementation_rounds,
|
||||
implement_model="provider/implement",
|
||||
implement_variant="high",
|
||||
),
|
||||
opencode=opencode,
|
||||
repository=repository,
|
||||
prompts=prompts,
|
||||
development=SimpleNamespace(description="Python 3.13"),
|
||||
),
|
||||
)
|
||||
return opencode, repository, prompts, services
|
||||
|
||||
|
||||
async def test_implementation_loop_persists_reviewed_revision_and_stops_clean() -> None:
|
||||
revised = AgentResult(summary_markdown="revision 1", tests=["pytest: passed"])
|
||||
opencode, repository, prompts, services = objects(
|
||||
[serious_report(), revised, clean_report()], implementation_rounds=4
|
||||
)
|
||||
run = RecordingRun()
|
||||
original = workflow()
|
||||
|
||||
updated, result, report = await review_implementation_loop(
|
||||
original,
|
||||
"issue context",
|
||||
"canonical plan",
|
||||
AgentResult(summary_markdown="initial", tests=[]),
|
||||
run,
|
||||
services,
|
||||
)
|
||||
|
||||
assert result == revised
|
||||
assert report == clean_report()
|
||||
assert original.reviewer_session_id is None
|
||||
assert updated.reviewer_session_id == "implementation-review-session"
|
||||
assert updated.artifact == revised.model_dump_json()
|
||||
assert updated.review_json == clean_report().model_dump_json()
|
||||
assert repository.saved_workflows[-1] == updated
|
||||
assert repository.saved_workflows[0].reviewer_session_id == ("implementation-review-session")
|
||||
assert run.stages == [
|
||||
"reviewing implementation 1/4",
|
||||
"reviewing implementation 2/4",
|
||||
]
|
||||
assert opencode.created_sessions == [(original.workspace_path, "implementation-review")]
|
||||
assert [call["session_id"] for call in opencode.resume_calls] == [
|
||||
"implementation-review-session",
|
||||
"primary-session",
|
||||
"implementation-review-session",
|
||||
]
|
||||
assert [call["result_type"] for call in opencode.resume_calls] == [
|
||||
ReviewReport,
|
||||
AgentResult,
|
||||
ReviewReport,
|
||||
]
|
||||
assert [name for name, _ in prompts.calls] == [
|
||||
"implementation_review",
|
||||
"implementation_revision",
|
||||
"implementation_review",
|
||||
]
|
||||
assert opencode.responses == []
|
||||
|
||||
|
||||
async def test_implementation_loop_never_makes_unreviewed_final_revision() -> None:
|
||||
final_report = serious_report("Still failing after the last review")
|
||||
opencode, repository, _, services = objects(
|
||||
[
|
||||
serious_report("round 1"),
|
||||
AgentResult(summary_markdown="revision 1", tests=[]),
|
||||
serious_report("round 2"),
|
||||
AgentResult(summary_markdown="revision 2", tests=[]),
|
||||
final_report,
|
||||
],
|
||||
implementation_rounds=3,
|
||||
)
|
||||
run = RecordingRun()
|
||||
|
||||
updated, result, report = await review_implementation_loop(
|
||||
workflow(),
|
||||
"issue context",
|
||||
"canonical plan",
|
||||
AgentResult(summary_markdown="initial", tests=[]),
|
||||
run,
|
||||
services,
|
||||
)
|
||||
|
||||
assert result.summary_markdown == "revision 2"
|
||||
assert report is final_report
|
||||
assert updated.artifact == result.model_dump_json()
|
||||
assert updated.review_json == final_report.model_dump_json()
|
||||
assert repository.saved_workflows[-1] == updated
|
||||
assert [call["result_type"] for call in opencode.resume_calls].count(ReviewReport) == 3
|
||||
assert [call["result_type"] for call in opencode.resume_calls].count(AgentResult) == 2
|
||||
assert run.stages == [
|
||||
"reviewing implementation 1/3",
|
||||
"reviewing implementation 2/3",
|
||||
"reviewing implementation 3/3",
|
||||
]
|
||||
assert opencode.responses == []
|
||||
|
||||
|
||||
async def test_implementation_review_once_reuses_existing_reviewer_session() -> None:
|
||||
opencode, repository, prompts, services = objects([clean_report()])
|
||||
existing = workflow(reviewer_session_id="existing-reviewer")
|
||||
|
||||
updated, report = await review_implementation_once(
|
||||
existing,
|
||||
issue_context="issue context",
|
||||
plan="canonical plan",
|
||||
pull_context="pull request context",
|
||||
services=services,
|
||||
)
|
||||
|
||||
assert updated is existing
|
||||
assert report == clean_report()
|
||||
assert opencode.created_sessions == []
|
||||
assert repository.saved_workflows == []
|
||||
assert opencode.resume_calls == [
|
||||
{
|
||||
"session_id": "existing-reviewer",
|
||||
"prompt": "rendered implementation_review",
|
||||
"model": "provider/implement",
|
||||
"variant": "high",
|
||||
"workspace": existing.workspace_path,
|
||||
"schema_name": "review.json",
|
||||
"result_type": ReviewReport,
|
||||
}
|
||||
]
|
||||
assert prompts.calls == [
|
||||
(
|
||||
"implementation_review",
|
||||
{
|
||||
"issue_context": "issue context",
|
||||
"artifact": "canonical plan",
|
||||
"pull_context": "pull request context",
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
async def test_plan_loop_revises_serious_finding_then_persists_clean_result() -> None:
|
||||
revised = PlanArtifact(plan_markdown="Revised plan")
|
||||
opencode, repository, prompts, services = objects(
|
||||
[serious_report(), revised, clean_report()], plan_rounds=4
|
||||
)
|
||||
run = RecordingRun()
|
||||
original = workflow()
|
||||
initial = PlanArtifact(plan_markdown="Initial plan")
|
||||
|
||||
updated, artifact, report = await review_plan_loop(
|
||||
original, "issue context", initial, run, services
|
||||
)
|
||||
|
||||
assert artifact is revised
|
||||
assert report == clean_report()
|
||||
assert updated.reviewer_session_id == "plan-review-session"
|
||||
assert updated.artifact == "Revised plan"
|
||||
assert updated.review_json == clean_report().model_dump_json()
|
||||
assert repository.saved_workflows[-1] == updated
|
||||
assert run.stages == ["reviewing plan 1/4", "reviewing plan 2/4"]
|
||||
assert [call["session_id"] for call in opencode.resume_calls] == [
|
||||
"plan-review-session",
|
||||
"primary-session",
|
||||
"plan-review-session",
|
||||
]
|
||||
assert [name for name, _ in prompts.calls] == [
|
||||
"plan_review",
|
||||
"plan_revision",
|
||||
"plan_review",
|
||||
]
|
||||
assert opencode.responses == []
|
||||
|
||||
|
||||
async def test_plan_loop_stops_at_round_boundary_without_unreviewed_revision() -> None:
|
||||
final_report = serious_report("round 2")
|
||||
opencode, repository, _, services = objects(
|
||||
[
|
||||
serious_report("round 1"),
|
||||
PlanArtifact(plan_markdown="Only revision"),
|
||||
final_report,
|
||||
],
|
||||
plan_rounds=2,
|
||||
)
|
||||
run = RecordingRun()
|
||||
|
||||
updated, artifact, report = await review_plan_loop(
|
||||
workflow(),
|
||||
"issue context",
|
||||
PlanArtifact(plan_markdown="Initial plan"),
|
||||
run,
|
||||
services,
|
||||
)
|
||||
|
||||
assert artifact.plan_markdown == "Only revision"
|
||||
assert report is final_report
|
||||
assert updated.artifact == "Only revision"
|
||||
assert updated.review_json == final_report.model_dump_json()
|
||||
assert repository.saved_workflows[-1] == updated
|
||||
assert [call["result_type"] for call in opencode.resume_calls] == [
|
||||
ReviewReport,
|
||||
PlanArtifact,
|
||||
ReviewReport,
|
||||
]
|
||||
assert run.stages == ["reviewing plan 1/2", "reviewing plan 2/2"]
|
||||
assert opencode.responses == []
|
||||
|
||||
|
||||
async def test_plan_review_once_creates_and_persists_reviewer_session() -> None:
|
||||
opencode, repository, prompts, services = objects([clean_report()])
|
||||
original = workflow()
|
||||
|
||||
updated, report = await review_plan_once(
|
||||
original,
|
||||
"issue context",
|
||||
PlanArtifact(plan_markdown="Plan body"),
|
||||
services,
|
||||
)
|
||||
|
||||
assert report == clean_report()
|
||||
assert updated is not original
|
||||
assert original.reviewer_session_id is None
|
||||
assert updated.reviewer_session_id == "plan-review-session"
|
||||
assert repository.saved_workflows == [updated]
|
||||
assert opencode.created_sessions == [(original.workspace_path, "plan-review")]
|
||||
assert opencode.resume_calls[0]["session_id"] == "plan-review-session"
|
||||
assert opencode.resume_calls[0]["result_type"] is ReviewReport
|
||||
assert prompts.calls == [
|
||||
(
|
||||
"plan_review",
|
||||
{"context": "issue context", "artifact": "Plan body"},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
async def test_minor_findings_end_review_loop_without_revision() -> None:
|
||||
opencode, repository, _, services = objects([minor_report()], implementation_rounds=5)
|
||||
run = RecordingRun()
|
||||
initial = AgentResult(summary_markdown="initial", tests=[])
|
||||
|
||||
updated, result, report = await review_implementation_loop(
|
||||
workflow(),
|
||||
"issue context",
|
||||
"canonical plan",
|
||||
initial,
|
||||
run,
|
||||
services,
|
||||
)
|
||||
|
||||
assert result is initial
|
||||
assert report == minor_report()
|
||||
assert not report.has_serious_findings
|
||||
assert opencode.reviews == 2
|
||||
assert opencode.revisions == 1
|
||||
|
||||
|
||||
async def test_does_not_make_unreviewed_final_revision() -> None:
|
||||
loop, opencode, workflow, job = objects(
|
||||
3, [serious_report(), serious_report(), serious_report()]
|
||||
)
|
||||
_, report = await loop.run(
|
||||
job,
|
||||
workflow,
|
||||
"issue context",
|
||||
"canonical plan",
|
||||
AgentResult(summary_markdown="initial", tests=[]),
|
||||
)
|
||||
assert report.has_serious_findings
|
||||
assert opencode.reviews == 3
|
||||
assert opencode.revisions == 2
|
||||
assert updated.artifact == initial.model_dump_json()
|
||||
assert updated.review_json == minor_report().model_dump_json()
|
||||
assert repository.saved_workflows[-1] == updated
|
||||
assert [call["result_type"] for call in opencode.resume_calls] == [ReviewReport]
|
||||
assert run.stages == ["reviewing implementation 1/5"]
|
||||
|
||||
+86
-7
@@ -1,13 +1,17 @@
|
||||
from pathlib import Path
|
||||
|
||||
from agentci.adapters.codegraph import CodeGraphClient
|
||||
import pytest
|
||||
|
||||
from agentci.codegraph import CodeGraph, CodeGraphError
|
||||
|
||||
|
||||
class FakeProcess:
|
||||
returncode = 0
|
||||
def __init__(self, returncode: int = 0, stderr: bytes = b"") -> None:
|
||||
self.returncode = returncode
|
||||
self.stderr = stderr
|
||||
|
||||
async def communicate(self) -> tuple[bytes, bytes]:
|
||||
return b"", b""
|
||||
return b"", self.stderr
|
||||
|
||||
|
||||
async def test_initializes_incomplete_index_and_excludes_it_from_git(
|
||||
@@ -23,11 +27,11 @@ async def test_initializes_incomplete_index_and_excludes_it_from_git(
|
||||
return FakeProcess()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"agentci.adapters.codegraph.asyncio.create_subprocess_exec",
|
||||
"agentci.codegraph.asyncio.create_subprocess_exec",
|
||||
create_subprocess_exec,
|
||||
)
|
||||
|
||||
await CodeGraphClient().prepare(workspace)
|
||||
await CodeGraph().prepare(workspace)
|
||||
|
||||
assert calls == [("codegraph", "init", str(workspace))]
|
||||
assert (workspace / ".git" / "info" / "exclude").read_text() == ".codegraph/\n"
|
||||
@@ -49,11 +53,86 @@ async def test_syncs_an_existing_index_without_duplicating_exclude(
|
||||
return FakeProcess()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"agentci.adapters.codegraph.asyncio.create_subprocess_exec",
|
||||
"agentci.codegraph.asyncio.create_subprocess_exec",
|
||||
create_subprocess_exec,
|
||||
)
|
||||
|
||||
await CodeGraphClient().prepare(workspace)
|
||||
await CodeGraph().prepare(workspace)
|
||||
|
||||
assert calls == [("codegraph", "sync", str(workspace))]
|
||||
assert exclude.read_text() == "# local excludes\n.codegraph/\n"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("initial", "expected"),
|
||||
[
|
||||
("*.pyc", "*.pyc\n.codegraph/\n"),
|
||||
("# .codegraph/ is documented here\n", "# .codegraph/ is documented here\n.codegraph/\n"),
|
||||
(" .codegraph/ \n", " .codegraph/ \n"),
|
||||
],
|
||||
)
|
||||
async def test_handles_exclude_file_boundaries(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
initial: str,
|
||||
expected: str,
|
||||
) -> None:
|
||||
workspace = tmp_path / "repo"
|
||||
exclude = workspace / ".git" / "info" / "exclude"
|
||||
exclude.parent.mkdir(parents=True)
|
||||
exclude.write_text(initial)
|
||||
|
||||
async def create_subprocess_exec(*_args, **_kwargs):
|
||||
return FakeProcess()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"agentci.codegraph.asyncio.create_subprocess_exec",
|
||||
create_subprocess_exec,
|
||||
)
|
||||
|
||||
await CodeGraph().prepare(workspace)
|
||||
|
||||
assert exclude.read_text() == expected
|
||||
|
||||
|
||||
async def test_reports_missing_executable(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
workspace = tmp_path / "repo"
|
||||
workspace.mkdir()
|
||||
|
||||
async def create_subprocess_exec(*_args, **_kwargs):
|
||||
raise FileNotFoundError(2, "No such file or directory", "codegraph")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"agentci.codegraph.asyncio.create_subprocess_exec",
|
||||
create_subprocess_exec,
|
||||
)
|
||||
|
||||
with pytest.raises(CodeGraphError, match="Could not run CodeGraph:.*codegraph") as raised:
|
||||
await CodeGraph().prepare(workspace)
|
||||
|
||||
assert isinstance(raised.value.__cause__, FileNotFoundError)
|
||||
assert (workspace / ".git" / "info" / "exclude").read_text() == ".codegraph/\n"
|
||||
|
||||
|
||||
async def test_reports_nonzero_exit_with_bounded_non_utf8_stderr(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
workspace = tmp_path / "repo"
|
||||
workspace.mkdir()
|
||||
stderr = b"discarded-prefix" + (b"x" * 1200) + b"\xff useful-tail"
|
||||
|
||||
async def create_subprocess_exec(*_args, **_kwargs):
|
||||
return FakeProcess(returncode=7, stderr=stderr)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"agentci.codegraph.asyncio.create_subprocess_exec",
|
||||
create_subprocess_exec,
|
||||
)
|
||||
|
||||
with pytest.raises(CodeGraphError, match="codegraph init failed") as raised:
|
||||
await CodeGraph().prepare(workspace)
|
||||
|
||||
message = str(raised.value)
|
||||
assert "discarded-prefix" not in message
|
||||
assert "useful-tail" in message
|
||||
assert len(message.removeprefix("codegraph init failed: ")) == 1000
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import pytest
|
||||
|
||||
from agentci.domain.commands import CommandError, parse_command, resolve_job_kind
|
||||
from agentci.domain.models import CommandName, JobKind
|
||||
from agentci.engine.commands import CommandError, parse_command, resolve_job_kind
|
||||
from agentci.engine.model import CommandName, JobKind
|
||||
|
||||
|
||||
def test_ignores_non_commands() -> None:
|
||||
@@ -14,8 +14,7 @@ def test_all_commands_accept_messages_after_any_number_of_lines(
|
||||
name: CommandName, line_breaks: int
|
||||
) -> None:
|
||||
command = parse_command(
|
||||
f"/agent {name.value}{'\n' * line_breaks}"
|
||||
"focus on the API\nand add tests"
|
||||
f"/agent {name.value}{'\n' * line_breaks}focus on the API\nand add tests"
|
||||
)
|
||||
assert command is not None
|
||||
assert command.name is name
|
||||
@@ -26,10 +25,7 @@ def test_all_commands_accept_messages_after_any_number_of_lines(
|
||||
def test_all_commands_accept_crlf_separated_multiline_messages(
|
||||
name: CommandName,
|
||||
) -> None:
|
||||
command = parse_command(
|
||||
f"/agent {name.value}\r\n\r\n\r\n"
|
||||
"focus on the API\r\nand add tests"
|
||||
)
|
||||
command = parse_command(f"/agent {name.value}\r\n\r\n\r\nfocus on the API\r\nand add tests")
|
||||
assert command is not None
|
||||
assert command.name is name
|
||||
assert command.message == "focus on the API\r\nand add tests"
|
||||
|
||||
+132
-43
@@ -1,61 +1,150 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from agentci.config import Settings
|
||||
|
||||
|
||||
def test_parses_comma_delimited_install_scripts() -> None:
|
||||
settings = Settings(
|
||||
_env_file=None, # type: ignore[call-arg]
|
||||
install_scripts=" python, dotnet, company-tools, ",
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_agentci_environment(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for name in tuple(os.environ):
|
||||
if name.startswith("AGENTCI_"):
|
||||
monkeypatch.delenv(name)
|
||||
|
||||
|
||||
def settings(**overrides: object) -> Settings:
|
||||
return Settings(_env_file=None, **overrides) # type: ignore[arg-type,call-arg]
|
||||
|
||||
|
||||
def test_reads_values_from_an_isolated_environment(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("AGENTCI_INSTALL_SCRIPTS", "python,dotnet")
|
||||
monkeypatch.setenv("AGENTCI_MAX_CONCURRENT_JOBS", "7")
|
||||
|
||||
value = settings()
|
||||
|
||||
assert value.install_scripts == ["python", "dotnet"]
|
||||
assert value.max_concurrent_jobs == 7
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["gitea_url", "opencode_url"])
|
||||
def test_normalizes_service_urls(field: str) -> None:
|
||||
value = settings(**{field: "https://service.example/base///"})
|
||||
|
||||
assert getattr(value, field) == "https://service.example/base"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "boundaries"),
|
||||
[
|
||||
("plan_review_rounds", (1, 20)),
|
||||
("implement_review_rounds", (1, 20)),
|
||||
("turn_timeout_seconds", (60,)),
|
||||
("install_script_timeout_seconds", (1,)),
|
||||
("worker_poll_seconds", (0.1,)),
|
||||
("max_concurrent_jobs", (1, 32)),
|
||||
],
|
||||
)
|
||||
def test_accepts_documented_numeric_boundaries(
|
||||
field: str, boundaries: tuple[int | float, ...]
|
||||
) -> None:
|
||||
for boundary in boundaries:
|
||||
assert getattr(settings(**{field: boundary}), field) == boundary
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value"),
|
||||
[
|
||||
("plan_review_rounds", 0),
|
||||
("plan_review_rounds", 21),
|
||||
("implement_review_rounds", 0),
|
||||
("implement_review_rounds", 21),
|
||||
("turn_timeout_seconds", 59),
|
||||
("install_script_timeout_seconds", 0),
|
||||
("worker_poll_seconds", 0.09),
|
||||
("max_concurrent_jobs", 0),
|
||||
("max_concurrent_jobs", 33),
|
||||
],
|
||||
)
|
||||
def test_rejects_values_outside_numeric_boundaries(field: str, value: int | float) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
settings(**{field: value})
|
||||
|
||||
|
||||
def test_reads_and_strips_secret_files(tmp_path: Path) -> None:
|
||||
token_file = tmp_path / "token"
|
||||
webhook_file = tmp_path / "webhook"
|
||||
password_file = tmp_path / "password"
|
||||
token_file.write_text(" gitea-token\n")
|
||||
webhook_file.write_text(" webhook-secret \n")
|
||||
password_file.write_text("opencode-password\n")
|
||||
value = settings(
|
||||
gitea_token_file=token_file,
|
||||
webhook_secret_file=webhook_file,
|
||||
opencode_server_password_file=password_file,
|
||||
)
|
||||
|
||||
assert settings.install_scripts == ["python", "dotnet", "company-tools"]
|
||||
assert value.gitea_token == "gitea-token"
|
||||
assert value.webhook_secret == b"webhook-secret"
|
||||
assert value.opencode_server_password == "opencode-password"
|
||||
|
||||
|
||||
def test_missing_secret_file_has_actionable_error(tmp_path: Path) -> None:
|
||||
missing = tmp_path / "missing-token"
|
||||
value = settings(gitea_token_file=missing)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Cannot read Gitea token") as raised:
|
||||
_ = value.gitea_token
|
||||
|
||||
assert str(missing) in str(raised.value)
|
||||
|
||||
|
||||
def test_empty_secret_file_is_rejected(tmp_path: Path) -> None:
|
||||
empty = tmp_path / "webhook"
|
||||
empty.write_text(" \n")
|
||||
value = settings(webhook_secret_file=empty)
|
||||
|
||||
with pytest.raises(RuntimeError, match="webhook secret file") as raised:
|
||||
_ = value.webhook_secret
|
||||
|
||||
assert str(empty) in str(raised.value)
|
||||
|
||||
|
||||
def test_derived_state_paths_follow_data_directory(tmp_path: Path) -> None:
|
||||
data_dir = tmp_path / "state"
|
||||
value = settings(data_dir=data_dir)
|
||||
|
||||
assert value.database_path == data_dir / "agentci.sqlite3"
|
||||
assert value.workspaces_dir == data_dir / "workspaces"
|
||||
|
||||
|
||||
def test_parses_comma_delimited_install_scripts() -> None:
|
||||
value = settings(install_scripts=" python, dotnet, company-tools, ")
|
||||
|
||||
assert value.install_scripts == ["python", "dotnet", "company-tools"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["../script", "tools/setup", "python,python"])
|
||||
def test_rejects_unsafe_or_duplicate_install_scripts(value: str) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Settings(_env_file=None, install_scripts=value) # type: ignore[call-arg]
|
||||
settings(install_scripts=value)
|
||||
|
||||
|
||||
def test_empty_install_scripts_disable_setup() -> None:
|
||||
settings = Settings(
|
||||
_env_file=None, # type: ignore[call-arg]
|
||||
install_scripts="",
|
||||
@pytest.mark.parametrize("value", ["", None, []])
|
||||
def test_empty_install_scripts_disable_setup(value: object) -> None:
|
||||
assert settings(install_scripts=value).install_scripts == []
|
||||
|
||||
|
||||
def test_agent_defaults_select_expected_capacity_and_research_models() -> None:
|
||||
value = settings()
|
||||
|
||||
assert value.max_concurrent_jobs == 2
|
||||
assert (value.explore_model, value.explore_variant) == (
|
||||
"openai/gpt-5.6-luna",
|
||||
"low",
|
||||
)
|
||||
assert settings.install_scripts == []
|
||||
|
||||
|
||||
def test_defaults_research_variant_to_high(monkeypatch) -> None:
|
||||
monkeypatch.delenv("AGENTCI_RESEARCH_VARIANT", raising=False)
|
||||
settings = Settings(_env_file=None) # type: ignore[call-arg]
|
||||
assert settings.research_variant == "high"
|
||||
|
||||
|
||||
def test_defaults_explore_agent_to_luna_low() -> None:
|
||||
settings = Settings(_env_file=None) # type: ignore[call-arg]
|
||||
assert settings.explore_model == "openai/gpt-5.6-luna"
|
||||
assert settings.explore_variant == "low"
|
||||
|
||||
|
||||
def test_defaults_to_two_concurrent_jobs() -> None:
|
||||
settings = Settings(_env_file=None) # type: ignore[call-arg]
|
||||
assert settings.max_concurrent_jobs == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [0, 33])
|
||||
def test_rejects_unsafe_job_concurrency(value: int) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Settings(_env_file=None, max_concurrent_jobs=value) # type: ignore[call-arg]
|
||||
|
||||
|
||||
def test_reads_comma_delimited_install_scripts_from_environment(monkeypatch) -> None:
|
||||
monkeypatch.setenv("AGENTCI_INSTALL_SCRIPTS", "python,dotnet")
|
||||
|
||||
settings = Settings(_env_file=None) # type: ignore[call-arg]
|
||||
|
||||
assert settings.install_scripts == ["python", "dotnet"]
|
||||
assert value.research_variant == "high"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -63,4 +152,4 @@ def test_reads_comma_delimited_install_scripts_from_environment(monkeypatch) ->
|
||||
)
|
||||
def test_requires_provider_qualified_opencode_models(field: str) -> None:
|
||||
with pytest.raises(ValidationError, match="provider/model"):
|
||||
Settings(_env_file=None, **{field: "model-only"}) # type: ignore[call-arg]
|
||||
settings(**{field: "model-only"})
|
||||
|
||||
+193
-6
@@ -1,8 +1,11 @@
|
||||
from agentci.adapters.gitea_models import CommentInfo, IssueInfo
|
||||
from agentci.workflows.context import ContextBuilder
|
||||
from typing import cast
|
||||
|
||||
from agentci.engine.repository import Repository
|
||||
from agentci.gitea import CommentInfo, Gitea, IssueInfo, PullRequestInfo
|
||||
from agentci.workflows.context import build_issue_context, build_pull_request_context
|
||||
|
||||
|
||||
class FakeGitea:
|
||||
class FakeIssueGitea:
|
||||
async def issue(self, *_args):
|
||||
return IssueInfo(number=2, title="Broken widget", body="It fails.", state="open")
|
||||
|
||||
@@ -13,15 +16,199 @@ class FakeGitea:
|
||||
]
|
||||
|
||||
|
||||
class FakeStorage:
|
||||
class FakeRepository:
|
||||
async def operational_comment_ids(self, *_args):
|
||||
return {2}
|
||||
|
||||
|
||||
class FakePullRequestGitea:
|
||||
async def pull_request(self, *_args):
|
||||
return PullRequestInfo(
|
||||
number=3,
|
||||
title="Fix widget",
|
||||
body="Fixes the failure.",
|
||||
state="open",
|
||||
merged=False,
|
||||
base_branch="main",
|
||||
head_branch="fix-widget",
|
||||
head_sha="abcdef1234567890",
|
||||
head_owner="alice",
|
||||
head_repo="repo",
|
||||
)
|
||||
|
||||
async def issue_comments(self, *_args):
|
||||
return [CommentInfo(3, "bob", "Please add a test.", "2026-01-03")]
|
||||
|
||||
async def pull_reviews(self, *_args):
|
||||
return [
|
||||
{
|
||||
"id": 4,
|
||||
"user": {"login": "carol"},
|
||||
"state": "REQUEST_CHANGES",
|
||||
"body": "One issue remains.",
|
||||
}
|
||||
]
|
||||
|
||||
async def review_comments(self, *_args):
|
||||
return [
|
||||
{
|
||||
"path": "src/widget.py",
|
||||
"new_position": 12,
|
||||
"body": "Handle the empty value.",
|
||||
}
|
||||
]
|
||||
|
||||
async def pull_commits(self, *_args):
|
||||
return [{"sha": "abcdef1234567890", "commit": {"message": "Fix widget"}}]
|
||||
|
||||
|
||||
class EmptyIssueGitea:
|
||||
async def issue(self, *_args):
|
||||
return IssueInfo(number=5, title="Empty issue", body="", state="open")
|
||||
|
||||
async def issue_comments(self, *_args):
|
||||
return []
|
||||
|
||||
|
||||
class EmptyRepository:
|
||||
async def operational_comment_ids(self, *_args):
|
||||
return set()
|
||||
|
||||
|
||||
class EmptyPullRequestGitea:
|
||||
async def pull_request(self, *_args):
|
||||
return PullRequestInfo(
|
||||
number=6,
|
||||
title="Empty pull request",
|
||||
body="",
|
||||
state="open",
|
||||
merged=False,
|
||||
base_branch="main",
|
||||
head_branch="empty",
|
||||
head_sha="1234567890abcdef",
|
||||
head_owner="alice",
|
||||
head_repo="repo",
|
||||
)
|
||||
|
||||
async def issue_comments(self, *_args):
|
||||
return []
|
||||
|
||||
async def pull_reviews(self, *_args):
|
||||
return []
|
||||
|
||||
async def pull_commits(self, *_args):
|
||||
return []
|
||||
|
||||
async def review_comments(self, *_args):
|
||||
raise AssertionError("review comments should not be requested without reviews")
|
||||
|
||||
|
||||
class OrderedPullRequestGitea(FakePullRequestGitea):
|
||||
async def issue_comments(self, *_args):
|
||||
return [
|
||||
CommentInfo(1, "alice", "First timeline comment", "2026-01-01"),
|
||||
CommentInfo(2, "bob", "Second timeline comment", "2026-01-02"),
|
||||
]
|
||||
|
||||
async def pull_reviews(self, *_args):
|
||||
return [
|
||||
{
|
||||
"id": 10,
|
||||
"user": {"login": "carol"},
|
||||
"state": "REQUEST_CHANGES",
|
||||
"body": "First formal review",
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"user": {"login": "dave"},
|
||||
"state": "APPROVED",
|
||||
"body": "Second formal review",
|
||||
},
|
||||
]
|
||||
|
||||
async def review_comments(self, *_args):
|
||||
review_id = _args[-1]
|
||||
if review_id == 10:
|
||||
return [
|
||||
{
|
||||
"path": "src/old.py",
|
||||
"new_position": None,
|
||||
"old_position": 21,
|
||||
"body": "Old-side position",
|
||||
}
|
||||
]
|
||||
return [
|
||||
{
|
||||
"path": "src/new.py",
|
||||
"new_position": 34,
|
||||
"body": "New-side position",
|
||||
}
|
||||
]
|
||||
|
||||
async def pull_commits(self, *_args):
|
||||
return [
|
||||
{"sha": "111111111111aaaa", "commit": {"message": "First commit"}},
|
||||
{"sha": "222222222222bbbb", "commit": {"message": "Second commit"}},
|
||||
]
|
||||
|
||||
|
||||
async def test_issue_context_excludes_operational_comments() -> None:
|
||||
builder = ContextBuilder(FakeGitea(), FakeStorage()) # type: ignore[arg-type]
|
||||
context = await builder.issue_context("org", "repo", 2)
|
||||
context = await build_issue_context(
|
||||
cast(Gitea, FakeIssueGitea()),
|
||||
cast(Repository, FakeRepository()),
|
||||
"org",
|
||||
"repo",
|
||||
2,
|
||||
)
|
||||
|
||||
assert "Broken widget" in context
|
||||
assert "Details" in context
|
||||
assert "Agent job queued" not in context
|
||||
|
||||
|
||||
async def test_pull_request_context_includes_feedback_and_commits() -> None:
|
||||
pull, context = await build_pull_request_context(
|
||||
cast(Gitea, FakePullRequestGitea()), "org", "repo", 3
|
||||
)
|
||||
|
||||
assert pull.number == 3
|
||||
assert "Please add a test." in context
|
||||
assert "One issue remains." in context
|
||||
assert "`src/widget.py:12`: Handle the empty value." in context
|
||||
assert "abcdef123456 Fix widget" in context
|
||||
|
||||
|
||||
async def test_issue_context_labels_empty_body_and_discussion() -> None:
|
||||
context = await build_issue_context(
|
||||
cast(Gitea, EmptyIssueGitea()),
|
||||
cast(Repository, EmptyRepository()),
|
||||
"org",
|
||||
"repo",
|
||||
5,
|
||||
)
|
||||
|
||||
assert "## Issue body\n(empty)" in context
|
||||
assert "## Discussion\n(none)" in context
|
||||
|
||||
|
||||
async def test_pull_request_context_labels_empty_sections() -> None:
|
||||
_, context = await build_pull_request_context(
|
||||
cast(Gitea, EmptyPullRequestGitea()), "org", "repo", 6
|
||||
)
|
||||
|
||||
assert "## Pull request body\n(empty)" in context
|
||||
assert "## Commits\n(none)" in context
|
||||
assert "## Timeline discussion\n(none)" in context
|
||||
assert "## Formal and inline reviews\n(none)" in context
|
||||
|
||||
|
||||
async def test_pull_request_context_preserves_source_order_and_positions() -> None:
|
||||
_, context = await build_pull_request_context(
|
||||
cast(Gitea, OrderedPullRequestGitea()), "org", "repo", 3
|
||||
)
|
||||
|
||||
assert context.index("First timeline comment") < context.index("Second timeline comment")
|
||||
assert context.index("111111111111 First commit") < context.index("222222222222 Second commit")
|
||||
assert context.index("Review 10 by carol") < context.index("Review 11 by dave")
|
||||
assert "`src/old.py:21`: Old-side position" in context
|
||||
assert "`src/new.py:34`: New-side position" in context
|
||||
|
||||
@@ -3,7 +3,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from agentci.adapters.development import (
|
||||
from agentci.development import (
|
||||
DevelopmentEnvironment,
|
||||
DevelopmentEnvironmentError,
|
||||
)
|
||||
@@ -37,7 +37,7 @@ async def test_runs_custom_scripts_in_order_with_sanitized_environment(
|
||||
development = environment(tmp_path, ["first", "second"])
|
||||
script(
|
||||
development.scripts_dir / "first",
|
||||
"printf 'first:%s:%s\\n' \"$DEV_TOOLS_DIR\" \"${AGENTCI_SECRET-unset}\" >> order",
|
||||
'printf \'first:%s:%s\\n\' "$DEV_TOOLS_DIR" "${AGENTCI_SECRET-unset}" >> order',
|
||||
)
|
||||
script(development.scripts_dir / "second", "printf 'second\\n' >> order")
|
||||
monkeypatch.setenv("AGENTCI_SECRET", "must-not-leak")
|
||||
@@ -112,6 +112,76 @@ async def test_reports_script_failure_output(tmp_path) -> None:
|
||||
await development.prepare(workspace)
|
||||
|
||||
|
||||
async def test_reports_missing_install_script(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
development = environment(tmp_path, ["missing"])
|
||||
|
||||
with pytest.raises(
|
||||
DevelopmentEnvironmentError,
|
||||
match=r"Install script 'missing' was not found",
|
||||
):
|
||||
await development.prepare(workspace)
|
||||
|
||||
|
||||
async def test_stops_before_second_script_after_failure(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
development = environment(tmp_path, ["first", "second"])
|
||||
script(development.scripts_dir / "first", "printf 'first\n' > first-ran; exit 3")
|
||||
script(development.scripts_dir / "second", "printf 'second\n' > second-ran")
|
||||
|
||||
with pytest.raises(DevelopmentEnvironmentError, match="exited with 3"):
|
||||
await development.prepare(workspace)
|
||||
|
||||
assert (workspace / "first-ran").read_text() == "first\n"
|
||||
assert not (workspace / "second-ran").exists()
|
||||
|
||||
|
||||
async def test_failure_output_keeps_only_bounded_tail(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
development = environment(tmp_path, ["verbose"])
|
||||
script(
|
||||
development.scripts_dir / "verbose",
|
||||
"printf 'discarded-prefix' >&2; "
|
||||
'i=0; while [ "$i" -lt 2100 ]; do printf x >&2; i=$((i + 1)); done; '
|
||||
"printf 'useful-tail' >&2; exit 9",
|
||||
)
|
||||
|
||||
with pytest.raises(DevelopmentEnvironmentError) as raised:
|
||||
await development.prepare(workspace)
|
||||
|
||||
message = str(raised.value)
|
||||
assert "discarded-prefix" not in message
|
||||
assert message.endswith("useful-tail")
|
||||
|
||||
|
||||
async def test_wraps_subprocess_start_error(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
development = environment(tmp_path, ["broken"])
|
||||
script(development.scripts_dir / "broken", "true")
|
||||
|
||||
async def create_subprocess_exec(*_args, **_kwargs):
|
||||
raise OSError("exec unavailable")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"agentci.development.asyncio.create_subprocess_exec",
|
||||
create_subprocess_exec,
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
DevelopmentEnvironmentError,
|
||||
match="Could not run install script 'broken': exec unavailable",
|
||||
) as raised:
|
||||
await development.prepare(workspace)
|
||||
|
||||
assert isinstance(raised.value.__cause__, OSError)
|
||||
|
||||
|
||||
async def test_times_out_install_script(tmp_path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import asyncio
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from agentci.git import Git, GitError
|
||||
|
||||
|
||||
class FakeProcess:
|
||||
def __init__(
|
||||
self,
|
||||
stdout: bytes = b"",
|
||||
stderr: bytes = b"",
|
||||
returncode: int = 0,
|
||||
) -> None:
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
self.returncode = returncode
|
||||
|
||||
async def communicate(self) -> tuple[bytes, bytes]:
|
||||
return self.stdout, self.stderr
|
||||
|
||||
|
||||
class SubprocessRecorder:
|
||||
def __init__(self, outcomes: Sequence[FakeProcess | OSError]) -> None:
|
||||
self.outcomes = list(outcomes)
|
||||
self.calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
|
||||
|
||||
async def __call__(self, *args: Any, **kwargs: Any) -> FakeProcess:
|
||||
self.calls.append((args, kwargs))
|
||||
outcome = self.outcomes.pop(0)
|
||||
if isinstance(outcome, OSError):
|
||||
raise outcome
|
||||
return outcome
|
||||
|
||||
@property
|
||||
def commands(self) -> list[tuple[Any, ...]]:
|
||||
return [args for args, _ in self.calls]
|
||||
|
||||
|
||||
def git_client(tmp_path: Path) -> Git:
|
||||
return Git(
|
||||
gitea_url="https://git.example.test/",
|
||||
username="agent-user",
|
||||
token="secret-token",
|
||||
askpass_path=tmp_path / "askpass.sh",
|
||||
commit_name="Agent CI",
|
||||
commit_email="agent@example.test",
|
||||
)
|
||||
|
||||
|
||||
def install_recorder(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
outcomes: Sequence[FakeProcess | OSError],
|
||||
) -> SubprocessRecorder:
|
||||
recorder = SubprocessRecorder(outcomes)
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_exec", recorder)
|
||||
return recorder
|
||||
|
||||
|
||||
async def test_clone_uses_authenticated_remote_and_returns_head(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
for name in (
|
||||
"GIT_ASKPASS",
|
||||
"GIT_TERMINAL_PROMPT",
|
||||
"AGENTCI_GIT_USERNAME",
|
||||
"AGENTCI_GIT_PASSWORD",
|
||||
):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
recorder = install_recorder(
|
||||
monkeypatch,
|
||||
[FakeProcess(), FakeProcess(stdout=b"abc123\n")],
|
||||
)
|
||||
destination = tmp_path / "workspaces" / "repo"
|
||||
|
||||
sha = await git_client(tmp_path).clone("org", "repo", "main", destination)
|
||||
|
||||
assert sha == "abc123"
|
||||
assert destination.parent.is_dir()
|
||||
assert recorder.commands == [
|
||||
(
|
||||
"git",
|
||||
"clone",
|
||||
"--branch",
|
||||
"main",
|
||||
"--single-branch",
|
||||
"https://git.example.test/org/repo.git",
|
||||
str(destination),
|
||||
),
|
||||
("git", "rev-parse", "HEAD"),
|
||||
]
|
||||
clone_kwargs = recorder.calls[0][1]
|
||||
assert clone_kwargs["cwd"] == destination.parent
|
||||
assert clone_kwargs["stdout"] is asyncio.subprocess.PIPE
|
||||
assert clone_kwargs["stderr"] is asyncio.subprocess.PIPE
|
||||
assert (
|
||||
clone_kwargs["env"]
|
||||
| {
|
||||
"GIT_ASKPASS": str(tmp_path / "askpass.sh"),
|
||||
"GIT_TERMINAL_PROMPT": "0",
|
||||
"AGENTCI_GIT_USERNAME": "agent-user",
|
||||
"AGENTCI_GIT_PASSWORD": "secret-token",
|
||||
}
|
||||
== clone_kwargs["env"]
|
||||
)
|
||||
current_sha_environment = recorder.calls[1][1]["env"]
|
||||
assert "AGENTCI_GIT_PASSWORD" not in current_sha_environment
|
||||
|
||||
|
||||
async def test_sync_branch_resets_and_cleans_before_returning_sha(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
recorder = install_recorder(
|
||||
monkeypatch,
|
||||
[FakeProcess(), FakeProcess(), FakeProcess(), FakeProcess(stdout=b"new-sha\n")],
|
||||
)
|
||||
workspace = tmp_path / "repo"
|
||||
|
||||
sha = await git_client(tmp_path).sync_branch(workspace, "feature")
|
||||
|
||||
assert sha == "new-sha"
|
||||
assert recorder.commands == [
|
||||
(
|
||||
"git",
|
||||
"fetch",
|
||||
"origin",
|
||||
"refs/heads/feature:refs/remotes/origin/feature",
|
||||
),
|
||||
("git", "reset", "--hard", "origin/feature"),
|
||||
("git", "clean", "-fd"),
|
||||
("git", "rev-parse", "HEAD"),
|
||||
]
|
||||
assert all(call[1]["cwd"] == workspace for call in recorder.calls)
|
||||
assert recorder.calls[0][1]["env"]["AGENTCI_GIT_PASSWORD"] == "secret-token"
|
||||
|
||||
|
||||
async def test_branch_status_and_diff_commands(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
recorder = install_recorder(
|
||||
monkeypatch,
|
||||
[FakeProcess(), FakeProcess(stdout=b" M src/app.py\n"), FakeProcess()],
|
||||
)
|
||||
workspace = tmp_path / "repo"
|
||||
git = git_client(tmp_path)
|
||||
|
||||
await git.create_branch(workspace, "agent/issue-1")
|
||||
changed = await git.has_changes(workspace)
|
||||
await git.diff_check(workspace)
|
||||
|
||||
assert changed is True
|
||||
assert recorder.commands == [
|
||||
("git", "switch", "-c", "agent/issue-1"),
|
||||
("git", "status", "--porcelain"),
|
||||
("git", "diff", "--check"),
|
||||
]
|
||||
|
||||
|
||||
async def test_has_changes_is_false_for_clean_status(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
install_recorder(monkeypatch, [FakeProcess(stdout=b"\n")])
|
||||
|
||||
assert await git_client(tmp_path).has_changes(tmp_path / "repo") is False
|
||||
|
||||
|
||||
async def test_commit_stages_all_changes_sets_identity_and_returns_sha(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
recorder = install_recorder(
|
||||
monkeypatch,
|
||||
[FakeProcess(), FakeProcess(), FakeProcess(stdout=b"commit-sha\n")],
|
||||
)
|
||||
workspace = tmp_path / "repo"
|
||||
|
||||
sha = await git_client(tmp_path).commit(workspace, "agent: Fix widget")
|
||||
|
||||
assert sha == "commit-sha"
|
||||
assert recorder.commands == [
|
||||
("git", "add", "-A"),
|
||||
(
|
||||
"git",
|
||||
"-c",
|
||||
"user.name=Agent CI",
|
||||
"-c",
|
||||
"user.email=agent@example.test",
|
||||
"commit",
|
||||
"-m",
|
||||
"agent: Fix widget",
|
||||
),
|
||||
("git", "rev-parse", "HEAD"),
|
||||
]
|
||||
|
||||
|
||||
async def test_push_command_supports_initial_and_existing_branches(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
recorder = install_recorder(monkeypatch, [FakeProcess(), FakeProcess()])
|
||||
workspace = tmp_path / "repo"
|
||||
git = git_client(tmp_path)
|
||||
|
||||
await git.push(workspace, "agent/new", set_upstream=True)
|
||||
await git.push(workspace, "agent/existing")
|
||||
|
||||
assert recorder.commands == [
|
||||
("git", "push", "--set-upstream", "origin", "agent/new"),
|
||||
("git", "push", "origin", "HEAD:agent/existing"),
|
||||
]
|
||||
assert all(call[1]["env"]["GIT_TERMINAL_PROMPT"] == "0" for call in recorder.calls)
|
||||
|
||||
|
||||
async def test_git_translates_process_start_failure(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
install_recorder(monkeypatch, [OSError("git executable missing")])
|
||||
|
||||
with pytest.raises(GitError, match="Could not run git rev-parse") as error:
|
||||
await git_client(tmp_path).current_sha(tmp_path / "repo")
|
||||
|
||||
assert isinstance(error.value.__cause__, OSError)
|
||||
|
||||
|
||||
async def test_git_translates_nonzero_exit_and_stderr(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
install_recorder(
|
||||
monkeypatch,
|
||||
[FakeProcess(stderr=b"fatal: invalid diff\n", returncode=2)],
|
||||
)
|
||||
|
||||
with pytest.raises(GitError, match="git diff failed: fatal: invalid diff"):
|
||||
await git_client(tmp_path).diff_check(tmp_path / "repo")
|
||||
|
||||
|
||||
async def test_commit_failure_identifies_commit_operation(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
install_recorder(
|
||||
monkeypatch,
|
||||
[FakeProcess(), FakeProcess(stderr=b"nothing to commit\n", returncode=1)],
|
||||
)
|
||||
|
||||
with pytest.raises(GitError, match="git commit failed: nothing to commit"):
|
||||
await git_client(tmp_path).commit(tmp_path / "repo", "agent: change")
|
||||
+334
-12
@@ -1,22 +1,344 @@
|
||||
import json
|
||||
from collections.abc import AsyncIterator, Callable, Coroutine
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import httpx
|
||||
import respx
|
||||
import pytest
|
||||
|
||||
from agentci.adapters.gitea import GiteaClient
|
||||
from agentci.gitea import CommentInfo, Gitea, GiteaError, IssueInfo, PullRequestInfo
|
||||
|
||||
Handler = Callable[[httpx.Request], Coroutine[None, None, httpx.Response]]
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_updates_issue_comment_by_id() -> None:
|
||||
route = respx.patch(
|
||||
"https://gitea.example/api/v1/repos/org/repo/issues/comments/17"
|
||||
).mock(return_value=httpx.Response(200, json={"id": 17}))
|
||||
client = GiteaClient("https://gitea.example", "secret")
|
||||
|
||||
@asynccontextmanager
|
||||
async def gitea_client(handler: Handler, *, retries: int = 3) -> AsyncIterator[Gitea]:
|
||||
client = Gitea(
|
||||
"https://gitea.example/",
|
||||
"secret",
|
||||
retries=retries,
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
try:
|
||||
await client.update_comment("org", "repo", 17, "updated status")
|
||||
yield client
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
assert route.called
|
||||
assert json.loads(route.calls[0].request.content) == {"body": "updated status"}
|
||||
|
||||
async def test_sends_authenticated_json_request_contract() -> None:
|
||||
requests: list[httpx.Request] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append(request)
|
||||
return httpx.Response(200, json={"id": 17})
|
||||
|
||||
async with gitea_client(handler) as client:
|
||||
assert await client.update_comment("org", "repo", 17, "updated status")
|
||||
|
||||
assert len(requests) == 1
|
||||
request = requests[0]
|
||||
assert request.method == "PATCH"
|
||||
assert request.url == httpx.URL(
|
||||
"https://gitea.example/api/v1/repos/org/repo/issues/comments/17"
|
||||
)
|
||||
assert request.headers["authorization"] == "token secret"
|
||||
assert request.headers["accept"] == "application/json"
|
||||
assert request.headers["content-type"] == "application/json"
|
||||
assert json.loads(request.content) == {"body": "updated status"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("permission", "expected"),
|
||||
[("write", True), ("ADMIN", True), ("owner", True), ("read", False), (None, False)],
|
||||
)
|
||||
async def test_maps_repository_permissions(permission: str | None, expected: bool) -> None:
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/api/v1/repos/org/repo/collaborators/alice/permission"
|
||||
return httpx.Response(200, json={"permission": permission})
|
||||
|
||||
async with gitea_client(handler) as client:
|
||||
assert await client.has_write_permission("org", "repo", "alice") is expected
|
||||
|
||||
|
||||
async def test_maps_issue_and_pull_request_responses() -> None:
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
match request.url.path:
|
||||
case "/api/v1/repos/org/repo":
|
||||
return httpx.Response(200, json={"default_branch": "trunk"})
|
||||
case "/api/v1/repos/org/repo/issues/12":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"title": "Issue title", "body": None, "state": "open"},
|
||||
)
|
||||
case "/api/v1/repos/org/repo/pulls/8":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"title": "Pull title",
|
||||
"body": None,
|
||||
"state": "open",
|
||||
"merged": False,
|
||||
"base": {"ref": "trunk"},
|
||||
"head": {
|
||||
"ref": "feature",
|
||||
"sha": "abc123",
|
||||
"repo": {"owner": {"login": "fork-owner"}, "name": "fork"},
|
||||
},
|
||||
},
|
||||
)
|
||||
raise AssertionError(f"unexpected request: {request.url}")
|
||||
|
||||
async with gitea_client(handler) as client:
|
||||
branch = await client.default_branch("org", "repo")
|
||||
issue = await client.issue("org", "repo", 12)
|
||||
pull = await client.pull_request("org", "repo", 8)
|
||||
|
||||
assert branch == "trunk"
|
||||
assert issue == IssueInfo(number=12, title="Issue title", body="", state="open")
|
||||
assert pull == PullRequestInfo(
|
||||
number=8,
|
||||
title="Pull title",
|
||||
body="",
|
||||
state="open",
|
||||
merged=False,
|
||||
base_branch="trunk",
|
||||
head_branch="feature",
|
||||
head_sha="abc123",
|
||||
head_owner="fork-owner",
|
||||
head_repo="fork",
|
||||
)
|
||||
assert pull.is_open
|
||||
|
||||
|
||||
async def test_creates_comment_and_pull_request_with_expected_payloads() -> None:
|
||||
requests: list[httpx.Request] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append(request)
|
||||
if request.url.path.endswith("/issues/4/comments"):
|
||||
return httpx.Response(201, json={"id": "23"})
|
||||
if request.method == "POST" and request.url.path.endswith("/pulls"):
|
||||
return httpx.Response(201, json={"number": 9})
|
||||
if request.method == "GET" and request.url.path.endswith("/pulls/9"):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"title": "Implement it",
|
||||
"body": "Details",
|
||||
"state": "open",
|
||||
"merged": False,
|
||||
"base": {"ref": "main"},
|
||||
"head": {
|
||||
"ref": "agent/work",
|
||||
"sha": "def456",
|
||||
"repo": {"owner": {"login": "org"}, "name": "repo"},
|
||||
},
|
||||
},
|
||||
)
|
||||
raise AssertionError(f"unexpected request: {request.method} {request.url}")
|
||||
|
||||
async with gitea_client(handler) as client:
|
||||
comment_id = await client.create_comment("org", "repo", 4, "Working")
|
||||
pull = await client.create_pull_request(
|
||||
"org",
|
||||
"repo",
|
||||
title="Implement it",
|
||||
body="Details",
|
||||
head="agent/work",
|
||||
base="main",
|
||||
)
|
||||
|
||||
assert comment_id == 23
|
||||
assert pull.number == 9
|
||||
assert [(request.method, request.url.path) for request in requests] == [
|
||||
("POST", "/api/v1/repos/org/repo/issues/4/comments"),
|
||||
("POST", "/api/v1/repos/org/repo/pulls"),
|
||||
("GET", "/api/v1/repos/org/repo/pulls/9"),
|
||||
]
|
||||
assert json.loads(requests[0].content) == {"body": "Working"}
|
||||
assert json.loads(requests[1].content) == {
|
||||
"title": "Implement it",
|
||||
"body": "Details",
|
||||
"head": "agent/work",
|
||||
"base": "main",
|
||||
}
|
||||
|
||||
|
||||
async def test_maps_allowed_not_found_responses_without_retry() -> None:
|
||||
paths: list[str] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
paths.append(request.url.path)
|
||||
return httpx.Response(404)
|
||||
|
||||
async with gitea_client(handler) as client:
|
||||
updated = await client.update_comment("org", "repo", 99, "missing")
|
||||
comments = await client.review_comments("org", "repo", 7, 3)
|
||||
|
||||
assert not updated
|
||||
assert comments == []
|
||||
assert paths == [
|
||||
"/api/v1/repos/org/repo/issues/comments/99",
|
||||
"/api/v1/repos/org/repo/pulls/7/reviews/3/comments",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("first_page_size", "expected_pages"), [(0, [1]), (49, [1]), (50, [1, 2])])
|
||||
async def test_pagination_stops_only_after_a_short_page(
|
||||
first_page_size: int, expected_pages: list[int]
|
||||
) -> None:
|
||||
pages: list[int] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/api/v1/repos/org/repo/issues/6/comments"
|
||||
assert request.url.params["limit"] == "50"
|
||||
page = int(request.url.params["page"])
|
||||
pages.append(page)
|
||||
size = first_page_size if page == 1 else 1
|
||||
offset = 0 if page == 1 else 50
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
{
|
||||
"id": offset + index + 1,
|
||||
"user": {"login": f"user-{offset + index + 1}"},
|
||||
"body": None,
|
||||
"created_at": None,
|
||||
}
|
||||
for index in range(size)
|
||||
],
|
||||
)
|
||||
|
||||
async with gitea_client(handler) as client:
|
||||
comments = await client.issue_comments("org", "repo", 6)
|
||||
|
||||
expected_count = first_page_size + (1 if first_page_size == 50 else 0)
|
||||
assert pages == expected_pages
|
||||
assert len(comments) == expected_count
|
||||
if comments:
|
||||
assert comments[0] == CommentInfo(id=1, author="user-1", body="", created_at="")
|
||||
assert comments[-1].id == expected_count
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [400, 401, 403, 404, 422])
|
||||
async def test_nonretryable_status_fails_once(status: int, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
attempts = 0
|
||||
sleeps: list[int] = []
|
||||
|
||||
async def handler(_request: httpx.Request) -> httpx.Response:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
return httpx.Response(status)
|
||||
|
||||
async def sleep(delay: int) -> None:
|
||||
sleeps.append(delay)
|
||||
|
||||
monkeypatch.setattr("agentci.gitea.asyncio.sleep", sleep)
|
||||
async with gitea_client(handler) as client:
|
||||
with pytest.raises(
|
||||
GiteaError,
|
||||
match=rf"Gitea returned {status} for GET /repos/org/repo",
|
||||
):
|
||||
await client.default_branch("org", "repo")
|
||||
|
||||
assert attempts == 1
|
||||
assert sleeps == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [429, 500, 502, 503, 504])
|
||||
async def test_retryable_status_recovers_after_backoff(
|
||||
status: int, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
attempts = 0
|
||||
sleeps: list[int] = []
|
||||
|
||||
async def handler(_request: httpx.Request) -> httpx.Response:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
if attempts == 1:
|
||||
return httpx.Response(status)
|
||||
return httpx.Response(200, json={"default_branch": "main"})
|
||||
|
||||
async def sleep(delay: int) -> None:
|
||||
sleeps.append(delay)
|
||||
|
||||
monkeypatch.setattr("agentci.gitea.asyncio.sleep", sleep)
|
||||
async with gitea_client(handler) as client:
|
||||
assert await client.default_branch("org", "repo") == "main"
|
||||
|
||||
assert attempts == 2
|
||||
assert sleeps == [1]
|
||||
|
||||
|
||||
async def test_retryable_status_exhaustion_uses_exponential_backoff(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
attempts = 0
|
||||
sleeps: list[int] = []
|
||||
|
||||
async def handler(_request: httpx.Request) -> httpx.Response:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
return httpx.Response(503)
|
||||
|
||||
async def sleep(delay: int) -> None:
|
||||
sleeps.append(delay)
|
||||
|
||||
monkeypatch.setattr("agentci.gitea.asyncio.sleep", sleep)
|
||||
async with gitea_client(handler) as client:
|
||||
with pytest.raises(
|
||||
GiteaError,
|
||||
match="Gitea remained unavailable for GET /repos/org/repo",
|
||||
):
|
||||
await client.default_branch("org", "repo")
|
||||
|
||||
assert attempts == 3
|
||||
assert sleeps == [1, 2]
|
||||
|
||||
|
||||
async def test_transport_failure_retries_and_recovers(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
attempts = 0
|
||||
sleeps: list[int] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
if attempts < 3:
|
||||
raise httpx.ConnectError("connection refused", request=request)
|
||||
return httpx.Response(200, json={"default_branch": "main"})
|
||||
|
||||
async def sleep(delay: int) -> None:
|
||||
sleeps.append(delay)
|
||||
|
||||
monkeypatch.setattr("agentci.gitea.asyncio.sleep", sleep)
|
||||
async with gitea_client(handler) as client:
|
||||
assert await client.default_branch("org", "repo") == "main"
|
||||
|
||||
assert attempts == 3
|
||||
assert sleeps == [1, 2]
|
||||
|
||||
|
||||
async def test_transport_failure_exhaustion_preserves_cause(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
attempts = 0
|
||||
sleeps: list[int] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
raise httpx.ConnectError("connection refused", request=request)
|
||||
|
||||
async def sleep(delay: int) -> None:
|
||||
sleeps.append(delay)
|
||||
|
||||
monkeypatch.setattr("agentci.gitea.asyncio.sleep", sleep)
|
||||
async with gitea_client(handler, retries=2) as client:
|
||||
with pytest.raises(
|
||||
GiteaError,
|
||||
match="Gitea request failed: GET /repos/org/repo",
|
||||
) as raised:
|
||||
await client.default_branch("org", "repo")
|
||||
|
||||
assert isinstance(raised.value.__cause__, httpx.ConnectError)
|
||||
assert attempts == 2
|
||||
assert sleeps == [1]
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient, Response
|
||||
|
||||
from agentci.health import router
|
||||
|
||||
|
||||
class Provider:
|
||||
def __init__(self, result: bool | Exception) -> None:
|
||||
self.result = result
|
||||
self.calls = 0
|
||||
|
||||
async def ready(self) -> bool:
|
||||
self.calls += 1
|
||||
if isinstance(self.result, Exception):
|
||||
raise self.result
|
||||
return self.result
|
||||
|
||||
|
||||
def application(provider: Provider | None = None) -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
if provider is not None:
|
||||
app.state.runtime = SimpleNamespace(opencode=provider)
|
||||
return app
|
||||
|
||||
|
||||
async def get(app: FastAPI, path: str) -> Response:
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app, raise_app_exceptions=False),
|
||||
base_url="http://test",
|
||||
) as client:
|
||||
return await client.get(path)
|
||||
|
||||
|
||||
async def test_liveness_does_not_depend_on_runtime_providers() -> None:
|
||||
response = await get(application(), "/health/live")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "live"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("provider_ready", "status_code", "payload"),
|
||||
[
|
||||
(True, 200, {"status": "ready"}),
|
||||
(
|
||||
False,
|
||||
503,
|
||||
{
|
||||
"status": "not-ready",
|
||||
"reason": "opencode provider is not connected",
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_readiness_reflects_provider_state(
|
||||
provider_ready: bool, status_code: int, payload: dict[str, str]
|
||||
) -> None:
|
||||
provider = Provider(provider_ready)
|
||||
|
||||
response = await get(application(provider), "/health/ready")
|
||||
|
||||
assert response.status_code == status_code
|
||||
assert response.json() == payload
|
||||
assert provider.calls == 1
|
||||
|
||||
|
||||
async def test_readiness_provider_error_is_server_failure() -> None:
|
||||
provider = Provider(RuntimeError("provider check failed"))
|
||||
|
||||
response = await get(application(provider), "/health/ready")
|
||||
|
||||
assert response.status_code == 500
|
||||
assert provider.calls == 1
|
||||
@@ -1,143 +0,0 @@
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from agentci.adapters.gitea_models import IssueInfo, PullRequestInfo, RepositoryInfo
|
||||
from agentci.domain.models import Job, JobKind, Workflow, WorkflowKind, WorkflowStatus
|
||||
from agentci.workflows.implement import ImplementWorkflow
|
||||
from agentci.workflows.pull_request import PullRequestWorkflow
|
||||
|
||||
|
||||
class SetupReached(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class FakeDevelopment:
|
||||
description = "python"
|
||||
|
||||
def __init__(self, events: list[str]) -> None:
|
||||
self.events = events
|
||||
|
||||
async def prepare(self, _workspace: Path) -> None:
|
||||
self.events.append("prepare")
|
||||
raise SetupReached
|
||||
|
||||
|
||||
class FakeGit:
|
||||
def __init__(self, events: list[str]) -> None:
|
||||
self.events = events
|
||||
|
||||
async def clone(self, *_args) -> str:
|
||||
self.events.append("clone")
|
||||
return "base-sha"
|
||||
|
||||
async def create_branch(self, *_args) -> None:
|
||||
self.events.append("create branch")
|
||||
|
||||
async def sync_branch(self, *_args) -> None:
|
||||
self.events.append("sync branch")
|
||||
|
||||
|
||||
class FakeStorage:
|
||||
def __init__(self, workflow: Workflow | None = None) -> None:
|
||||
self.workflow = workflow
|
||||
|
||||
async def implementation_workflows(self, *_args):
|
||||
return []
|
||||
|
||||
async def create_workflow(self, _workflow) -> None:
|
||||
return None
|
||||
|
||||
async def update_job(self, *_args, **_kwargs) -> None:
|
||||
return None
|
||||
|
||||
async def workflow_for_pr(self, *_args):
|
||||
return self.workflow
|
||||
|
||||
|
||||
class FakeContext:
|
||||
def __init__(self, pull: PullRequestInfo) -> None:
|
||||
self.pull = pull
|
||||
|
||||
async def pull_request_context(self, *_args):
|
||||
return self.pull, "context"
|
||||
|
||||
|
||||
class FakeGitea:
|
||||
async def repository(self, *_args) -> RepositoryInfo:
|
||||
return RepositoryInfo("org", "repo", "org/repo", "main")
|
||||
|
||||
async def issue(self, *_args) -> IssueInfo:
|
||||
return IssueInfo(1, "Issue", "Body", "open")
|
||||
|
||||
|
||||
def job(kind: JobKind, *, pr_number: int | None = None) -> Job:
|
||||
return Job(
|
||||
id="job",
|
||||
kind=kind,
|
||||
target_key="org/repo:target",
|
||||
repo_owner="org",
|
||||
repo_name="repo",
|
||||
issue_number=1,
|
||||
pr_number=pr_number,
|
||||
requester="alice",
|
||||
message="",
|
||||
comment_id=1,
|
||||
)
|
||||
|
||||
|
||||
def pull() -> PullRequestInfo:
|
||||
return PullRequestInfo(2, "PR", "Body", "open", False, "main", "agent", "sha", "org", "repo")
|
||||
|
||||
|
||||
async def test_initial_implementation_prepares_after_clone_and_branch(tmp_path) -> None:
|
||||
events: list[str] = []
|
||||
settings = SimpleNamespace(branch_prefix="agent", workspaces_dir=tmp_path)
|
||||
deps = SimpleNamespace(
|
||||
settings=settings,
|
||||
storage=FakeStorage(),
|
||||
gitea=FakeGitea(),
|
||||
git=FakeGit(events),
|
||||
development=FakeDevelopment(events),
|
||||
)
|
||||
|
||||
with pytest.raises(SetupReached):
|
||||
await ImplementWorkflow(deps).run(job(JobKind.IMPLEMENT)) # type: ignore[arg-type]
|
||||
|
||||
assert events == ["clone", "create branch", "prepare"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("operation", ["iterate", "fix"])
|
||||
async def test_pull_request_implementation_prepares_after_checkout(
|
||||
tmp_path, operation: str
|
||||
) -> None:
|
||||
events: list[str] = []
|
||||
existing = Workflow(
|
||||
id="flow",
|
||||
kind=WorkflowKind.IMPLEMENT,
|
||||
repo_owner="org",
|
||||
repo_name="repo",
|
||||
issue_number=1,
|
||||
workspace_path=tmp_path / "repo",
|
||||
base_sha="base",
|
||||
branch="agent",
|
||||
primary_session_id="primary",
|
||||
reviewer_session_id="reviewer",
|
||||
status=WorkflowStatus.COMPLETED,
|
||||
)
|
||||
settings = SimpleNamespace(workspaces_dir=tmp_path)
|
||||
deps = SimpleNamespace(
|
||||
settings=settings,
|
||||
storage=FakeStorage(existing),
|
||||
context=FakeContext(pull()),
|
||||
git=FakeGit(events),
|
||||
development=FakeDevelopment(events),
|
||||
)
|
||||
workflow = PullRequestWorkflow(deps) # type: ignore[arg-type]
|
||||
|
||||
with pytest.raises(SetupReached):
|
||||
await getattr(workflow, operation)(job(JobKind.FIX, pr_number=2))
|
||||
|
||||
expected_checkout = "sync branch" if operation == "iterate" else "clone"
|
||||
assert events == [expected_checkout, "prepare"]
|
||||
@@ -1,12 +1,154 @@
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
|
||||
def test_dotnet_wrapper_uses_persistent_runtime_directories() -> None:
|
||||
root = Path(__file__).parents[1]
|
||||
script = (root / "install-scripts" / "dotnet").read_text()
|
||||
|
||||
assert "export DOTNET_ROOT=" in script
|
||||
assert "/tmp/agentci-dotnet" not in script
|
||||
assert "DEV_TOOLS_DIR/runtime/dotnet" in script
|
||||
assert "NUGET_PACKAGES" in script
|
||||
assert 'export HOME="$DOTNET_CLI_HOME"' in script
|
||||
def executable(path: Path, content: str) -> None:
|
||||
path.write_text(content)
|
||||
path.chmod(0o755)
|
||||
|
||||
|
||||
def test_python_installer_configures_uv_and_stable_python_links(tmp_path: Path) -> None:
|
||||
fake_bin = tmp_path / "fake-bin"
|
||||
fake_bin.mkdir()
|
||||
tools = tmp_path / "tools"
|
||||
uv_log = tmp_path / "uv.log"
|
||||
executable(
|
||||
fake_bin / "uv",
|
||||
"""#!/bin/sh
|
||||
set -eu
|
||||
{
|
||||
printf 'UV_NO_CONFIG=%s\n' "$UV_NO_CONFIG"
|
||||
printf 'UV_PYTHON_INSTALL_DIR=%s\n' "$UV_PYTHON_INSTALL_DIR"
|
||||
printf 'UV_PYTHON_BIN_DIR=%s\n' "$UV_PYTHON_BIN_DIR"
|
||||
printf 'UV_PYTHON_INSTALL_BIN=%s\n' "$UV_PYTHON_INSTALL_BIN"
|
||||
printf 'args=%s %s %s\n' "$1" "$2" "$3"
|
||||
} > "$FAKE_UV_LOG"
|
||||
minor=$(printf '%s' "$3" | cut -d. -f1,2)
|
||||
mkdir -p "$UV_PYTHON_INSTALL_DIR" "$UV_PYTHON_BIN_DIR"
|
||||
cat > "$UV_PYTHON_BIN_DIR/python$minor" <<'PYTHON'
|
||||
#!/bin/sh
|
||||
printf '%s\n' 'Python fake'
|
||||
PYTHON
|
||||
chmod 0755 "$UV_PYTHON_BIN_DIR/python$minor"
|
||||
""",
|
||||
)
|
||||
environment = {
|
||||
**os.environ,
|
||||
"PATH": f"{fake_bin}:{os.environ['PATH']}",
|
||||
"DEV_TOOLS_DIR": str(tools),
|
||||
"PYTHON_VERSION": "3.13.7",
|
||||
"FAKE_UV_LOG": str(uv_log),
|
||||
}
|
||||
|
||||
result = subprocess.run(
|
||||
["/bin/sh", str(ROOT / "install-scripts" / "python")],
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout == "Python fake\n"
|
||||
assert uv_log.read_text().splitlines() == [
|
||||
"UV_NO_CONFIG=1",
|
||||
f"UV_PYTHON_INSTALL_DIR={tools / 'python'}",
|
||||
f"UV_PYTHON_BIN_DIR={tools / 'bin'}",
|
||||
"UV_PYTHON_INSTALL_BIN=1",
|
||||
"args=python install 3.13.7",
|
||||
]
|
||||
assert (tools / "bin" / "python").readlink() == Path("python3.13")
|
||||
assert (tools / "bin" / "python3").readlink() == Path("python3.13")
|
||||
|
||||
|
||||
def test_dotnet_installer_builds_persistent_runtime_wrapper_without_network(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fake_bin = tmp_path / "fake-bin"
|
||||
fake_bin.mkdir()
|
||||
tools = tmp_path / "tools"
|
||||
installer_log = tmp_path / "installer.log"
|
||||
dotnet_log = tmp_path / "dotnet.log"
|
||||
executable(
|
||||
fake_bin / "python3",
|
||||
"""#!/bin/sh
|
||||
set -eu
|
||||
[ "$1" = - ]
|
||||
cat > "$2" <<'INSTALLER'
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
printf '%s\n' "$@" > "$FAKE_INSTALLER_LOG"
|
||||
install_dir=
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--install-dir) install_dir=$2; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
mkdir -p "$install_dir"
|
||||
cat > "$install_dir/dotnet" <<'DOTNET'
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
printf '%s|%s|%s|%s|%s|%s\n' \
|
||||
"$*" "$DOTNET_ROOT" "$DOTNET_CLI_HOME" "$NUGET_PACKAGES" \
|
||||
"$NUGET_HTTP_CACHE_PATH" "$HOME" >> "$FAKE_DOTNET_LOG"
|
||||
DOTNET
|
||||
chmod 0755 "$install_dir/dotnet"
|
||||
INSTALLER
|
||||
""",
|
||||
)
|
||||
environment = {
|
||||
**os.environ,
|
||||
"PATH": f"{fake_bin}:{os.environ['PATH']}",
|
||||
"DEV_TOOLS_DIR": str(tools),
|
||||
"DOTNET_CHANNEL": "10.0",
|
||||
"FAKE_INSTALLER_LOG": str(installer_log),
|
||||
"FAKE_DOTNET_LOG": str(dotnet_log),
|
||||
}
|
||||
|
||||
result = subprocess.run(
|
||||
["/bin/sh", str(ROOT / "install-scripts" / "dotnet")],
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert installer_log.read_text().splitlines() == [
|
||||
"--channel",
|
||||
"10.0",
|
||||
"--install-dir",
|
||||
str(tools / "dotnet"),
|
||||
"--no-path",
|
||||
]
|
||||
wrapper = tools / "bin" / "dotnet"
|
||||
assert os.access(wrapper, os.X_OK)
|
||||
|
||||
wrapped = subprocess.run(
|
||||
[wrapper, "--version"],
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert wrapped.returncode == 0, wrapped.stderr
|
||||
calls = dotnet_log.read_text().splitlines()
|
||||
assert calls[0].split("|")[0] == "--info"
|
||||
assert calls[1].split("|") == [
|
||||
"--version",
|
||||
str(tools / "dotnet"),
|
||||
str(tools / "runtime" / "dotnet" / "home"),
|
||||
str(tools / "runtime" / "dotnet" / "nuget" / "packages"),
|
||||
str(tools / "runtime" / "dotnet" / "nuget" / "http-cache"),
|
||||
str(tools / "runtime" / "dotnet" / "home"),
|
||||
]
|
||||
assert (tools / "runtime" / "dotnet" / "home").is_dir()
|
||||
assert (tools / "runtime" / "dotnet" / "nuget" / "packages").is_dir()
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from agentci.engine.events import (
|
||||
JobProgress,
|
||||
RuntimeSessionLinked,
|
||||
WorkflowCreated,
|
||||
WorkflowLinked,
|
||||
)
|
||||
from agentci.engine.model import Workflow, WorkflowKind
|
||||
from agentci.engine.run import JobRun
|
||||
|
||||
|
||||
class RecordingRepository:
|
||||
def __init__(self, *, fail_event_ids: set[str] | None = None) -> None:
|
||||
self.events: list[tuple[str, object]] = []
|
||||
self.fail_event_ids = fail_event_ids or set()
|
||||
|
||||
async def apply(self, event_id: str, event: object) -> None:
|
||||
self.events.append((event_id, event))
|
||||
if event_id in self.fail_event_ids:
|
||||
raise RuntimeError("write failed")
|
||||
|
||||
|
||||
async def test_job_run_emits_each_report_payload_with_monotonic_ids() -> None:
|
||||
repository = RecordingRepository()
|
||||
run = JobRun(repository, "job-1", 10) # type: ignore[arg-type]
|
||||
workflow = Workflow(
|
||||
id="workflow-1",
|
||||
kind=WorkflowKind.PLAN,
|
||||
repo_owner="alice",
|
||||
repo_name="repo",
|
||||
issue_number=3,
|
||||
workspace_path=Path("/work/repo"),
|
||||
base_sha="abc",
|
||||
)
|
||||
|
||||
await run.stage("cloning")
|
||||
await run.create_workflow(workflow, "planning")
|
||||
await run.link_workflow("workflow-2", "discussing")
|
||||
await run.link_session("session-1")
|
||||
|
||||
assert repository.events == [
|
||||
("task:10:report:1", JobProgress(job_id="job-1", stage="cloning")),
|
||||
(
|
||||
"task:10:report:2",
|
||||
WorkflowCreated(job_id="job-1", workflow=workflow, stage="planning"),
|
||||
),
|
||||
(
|
||||
"task:10:report:3",
|
||||
WorkflowLinked(job_id="job-1", workflow_id="workflow-2", stage="discussing"),
|
||||
),
|
||||
(
|
||||
"task:10:report:4",
|
||||
RuntimeSessionLinked(job_id="job-1", session_id="session-1"),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def test_failed_report_consumes_only_its_run_sequence_number() -> None:
|
||||
repository = RecordingRepository(fail_event_ids={"task:10:report:1"})
|
||||
first = JobRun(repository, "job-1", 10) # type: ignore[arg-type]
|
||||
second = JobRun(repository, "job-2", 20) # type: ignore[arg-type]
|
||||
|
||||
with pytest.raises(RuntimeError, match="write failed"):
|
||||
await first.stage("cloning")
|
||||
await second.stage("fixing")
|
||||
await first.link_session("session-1")
|
||||
|
||||
assert [event_id for event_id, _ in repository.events] == [
|
||||
"task:10:report:1",
|
||||
"task:20:report:1",
|
||||
"task:10:report:2",
|
||||
]
|
||||
@@ -1,13 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_python_files_are_at_most_250_lines() -> None:
|
||||
root = Path(__file__).parents[1]
|
||||
files = [*root.glob("src/**/*.py"), *root.glob("tests/**/*.py")]
|
||||
oversized = {
|
||||
str(path.relative_to(root)): len(path.read_text().splitlines())
|
||||
for path in files
|
||||
if len(path.read_text().splitlines()) > 250
|
||||
}
|
||||
assert oversized == {}
|
||||
|
||||
+98
-2
@@ -1,10 +1,106 @@
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from collections.abc import Iterator
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from agentci.logging import configure_logging
|
||||
import pytest
|
||||
|
||||
from agentci.logging import JsonFormatter, configure_logging
|
||||
|
||||
|
||||
def test_suppresses_http_client_request_logs() -> None:
|
||||
@pytest.fixture(autouse=True)
|
||||
def restore_logging_state() -> Iterator[None]:
|
||||
root = logging.getLogger()
|
||||
original_handlers = root.handlers[:]
|
||||
original_level = root.level
|
||||
client_levels = {name: logging.getLogger(name).level for name in ("httpx", "httpcore")}
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
root.handlers[:] = original_handlers
|
||||
root.setLevel(original_level)
|
||||
for name, level in client_levels.items():
|
||||
logging.getLogger(name).setLevel(level)
|
||||
|
||||
|
||||
def record(*, message: str = "processed job") -> logging.LogRecord:
|
||||
return logging.LogRecord(
|
||||
name="agentci.test",
|
||||
level=logging.INFO,
|
||||
pathname=__file__,
|
||||
lineno=1,
|
||||
msg=message,
|
||||
args=(),
|
||||
exc_info=None,
|
||||
)
|
||||
|
||||
|
||||
def test_json_formatter_emits_stable_structured_contract() -> None:
|
||||
value = record()
|
||||
value.__dict__.update(
|
||||
operation="worker.execute",
|
||||
job_id="job-1",
|
||||
duration_ms=0,
|
||||
unapproved_secret="not-for-output",
|
||||
)
|
||||
|
||||
payload = json.loads(JsonFormatter().format(value))
|
||||
|
||||
assert payload == {
|
||||
"timestamp": payload["timestamp"],
|
||||
"level": "INFO",
|
||||
"logger": "agentci.test",
|
||||
"message": "processed job",
|
||||
"operation": "worker.execute",
|
||||
"job_id": "job-1",
|
||||
"duration_ms": 0,
|
||||
}
|
||||
timestamp = datetime.fromisoformat(payload["timestamp"])
|
||||
assert timestamp.tzinfo == UTC
|
||||
|
||||
|
||||
def test_json_formatter_includes_exception_traceback() -> None:
|
||||
try:
|
||||
raise RuntimeError("provider unavailable")
|
||||
except RuntimeError:
|
||||
value = record(message="request failed")
|
||||
value.exc_info = sys.exc_info()
|
||||
|
||||
payload = json.loads(JsonFormatter().format(value))
|
||||
|
||||
assert payload["message"] == "request failed"
|
||||
assert "RuntimeError: provider unavailable" in payload["exception"]
|
||||
|
||||
|
||||
def test_json_formatter_survives_unserializable_context() -> None:
|
||||
value = record()
|
||||
value.__dict__["operation"] = object()
|
||||
|
||||
payload = json.loads(JsonFormatter().format(value))
|
||||
|
||||
assert payload["level"] == "INFO"
|
||||
assert payload["logger"] == "agentci.test"
|
||||
assert payload["message"] == "Log record could not be serialized"
|
||||
assert "TypeError" in payload["exception"]
|
||||
|
||||
|
||||
def test_repeated_configuration_replaces_handlers_without_duplicate_output(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
configure_logging()
|
||||
configure_logging()
|
||||
|
||||
root = logging.getLogger()
|
||||
assert root.level == logging.INFO
|
||||
assert len(root.handlers) == 1
|
||||
assert isinstance(root.handlers[0].formatter, JsonFormatter)
|
||||
assert logging.getLogger("httpx").level == logging.WARNING
|
||||
assert logging.getLogger("httpcore").level == logging.WARNING
|
||||
|
||||
logging.getLogger("agentci.contract").info(
|
||||
"configured", extra={"operation": "logging.configure"}
|
||||
)
|
||||
lines = capsys.readouterr().err.splitlines()
|
||||
assert len(lines) == 1
|
||||
assert json.loads(lines[0])["operation"] == "logging.configure"
|
||||
|
||||
+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()
|
||||
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@ from pathlib import Path
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from agentci.adapters.opencode import OpenCodeClient, OpenCodeError
|
||||
from agentci.opencode import OpenCode, OpenCodeError
|
||||
|
||||
|
||||
def client(tmp_path: Path, status: int) -> OpenCodeClient:
|
||||
return OpenCodeClient(
|
||||
def client(tmp_path: Path, status: int) -> OpenCode:
|
||||
return OpenCode(
|
||||
base_url="http://opencode:4096",
|
||||
username="opencode",
|
||||
password="secret",
|
||||
@@ -32,3 +32,50 @@ async def test_abort_failure_is_visible_for_retry(tmp_path: Path, status: int) -
|
||||
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 = 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),
|
||||
)
|
||||
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 = 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),
|
||||
)
|
||||
|
||||
await value.abort("session", tmp_path, best_effort=True)
|
||||
await value.close()
|
||||
|
||||
@@ -1,10 +1,81 @@
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
|
||||
|
||||
def _indent(line: str) -> int:
|
||||
return len(line) - len(line.lstrip())
|
||||
|
||||
|
||||
def _service(name: str) -> list[str]:
|
||||
lines = (ROOT / "compose.yaml").read_text().splitlines()
|
||||
marker = f" {name}:"
|
||||
start = lines.index(marker) + 1
|
||||
end = next(
|
||||
(
|
||||
index
|
||||
for index in range(start, len(lines))
|
||||
if lines[index].strip() and _indent(lines[index]) <= 2
|
||||
),
|
||||
len(lines),
|
||||
)
|
||||
return lines[start:end]
|
||||
|
||||
|
||||
def _section(lines: list[str], name: str, *, indent: int = 4) -> list[str]:
|
||||
marker = f"{' ' * indent}{name}:"
|
||||
start = lines.index(marker) + 1
|
||||
end = next(
|
||||
(
|
||||
index
|
||||
for index in range(start, len(lines))
|
||||
if lines[index].strip() and _indent(lines[index]) <= indent
|
||||
),
|
||||
len(lines),
|
||||
)
|
||||
return lines[start:end]
|
||||
|
||||
|
||||
def _value(value: str) -> str:
|
||||
value = value.strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] == '"':
|
||||
return str(json.loads(value))
|
||||
if len(value) >= 2 and value[0] == value[-1] == "'":
|
||||
return value[1:-1]
|
||||
return value
|
||||
|
||||
|
||||
def _mapping(lines: list[str], name: str, *, indent: int = 4) -> dict[str, str]:
|
||||
entries = _section(lines, name, indent=indent)
|
||||
result: dict[str, str] = {}
|
||||
for line in entries:
|
||||
if _indent(line) != indent + 2 or line.lstrip().startswith("-"):
|
||||
continue
|
||||
key, separator, value = line.strip().partition(":")
|
||||
if separator:
|
||||
result[key] = _value(value)
|
||||
return result
|
||||
|
||||
|
||||
def _sequence(lines: list[str], name: str, *, indent: int = 4) -> list[str]:
|
||||
entries = _section(lines, name, indent=indent)
|
||||
prefix = f"{' ' * (indent + 2)}- "
|
||||
return [_value(line.removeprefix(prefix)) for line in entries if line.startswith(prefix)]
|
||||
|
||||
|
||||
def _scalar(lines: list[str], name: str, *, indent: int = 4) -> str:
|
||||
prefix = f"{' ' * indent}{name}:"
|
||||
line = next(line for line in lines if line.startswith(prefix))
|
||||
return _value(line.removeprefix(prefix))
|
||||
|
||||
|
||||
def test_config_preserves_builtin_permissions_and_restricts_research() -> None:
|
||||
root = Path(__file__).parents[1]
|
||||
config = json.loads((root / "opencode" / "opencode.json").read_text())
|
||||
config = json.loads((ROOT / "opencode" / "opencode.json").read_text())
|
||||
|
||||
assert "permission" not in config
|
||||
assert all(name not in config["agent"] for name in ("build", "plan", "general"))
|
||||
@@ -22,22 +93,100 @@ def test_config_preserves_builtin_permissions_and_restricts_research() -> None:
|
||||
assert config["agent"]["research"]["variant"] == "{env:AGENTCI_RESEARCH_VARIANT}"
|
||||
|
||||
|
||||
def test_compose_removes_codex_sandbox_exceptions() -> None:
|
||||
root = Path(__file__).parents[1]
|
||||
compose = (root / "compose.yaml").read_text()
|
||||
dockerfile = (root / "Dockerfile").read_text()
|
||||
def test_compose_services_have_expected_runtime_contract() -> None:
|
||||
agentci = _service("agentci")
|
||||
opencode = _service("opencode")
|
||||
agentci_environment = _mapping(agentci, "environment")
|
||||
opencode_environment = _mapping(opencode, "environment")
|
||||
|
||||
for forbidden in ("cap_add", "seccomp=unconfined", "apparmor=unconfined", "bubblewrap"):
|
||||
assert agentci_environment["AGENTCI_OPENCODE_URL"] == "http://opencode:4096"
|
||||
assert agentci_environment["AGENTCI_EXPLORE_VARIANT"] == ("${AGENTCI_EXPLORE_VARIANT:-low}")
|
||||
assert agentci_environment["AGENTCI_RESEARCH_VARIANT"] == ("${AGENTCI_RESEARCH_VARIANT:-high}")
|
||||
assert opencode_environment["HOME"] == "/etc/opencode/home"
|
||||
assert opencode_environment["OPENCODE_DISABLE_EXTERNAL_SKILLS"] == "1"
|
||||
assert opencode_environment["OPENCODE_ENABLE_EXA"] == "1"
|
||||
assert "OPENCODE_DISABLE_DEFAULT_PLUGINS" not in opencode_environment
|
||||
|
||||
assert _sequence(agentci, "volumes") == [
|
||||
"agentci_data:/var/lib/agentci",
|
||||
"./install-scripts:/etc/agentci/install-scripts:ro",
|
||||
]
|
||||
assert _sequence(opencode, "volumes") == [
|
||||
"agentci_data:/var/lib/agentci",
|
||||
"opencode_home:/var/lib/opencode",
|
||||
]
|
||||
assert _sequence(agentci, "tmpfs") == ["/run/agentci:mode=1777"]
|
||||
assert _sequence(opencode, "tmpfs") == ["/run/agentci:mode=1777"]
|
||||
assert json.loads(_scalar(opencode, "command")) == [
|
||||
"opencode",
|
||||
"serve",
|
||||
"--hostname",
|
||||
"0.0.0.0",
|
||||
"--port",
|
||||
"4096",
|
||||
]
|
||||
|
||||
build = _section(agentci, "build")
|
||||
assert _mapping(build, "args", indent=6)["AGENTCI_OPENCODE_VERSION"] == (
|
||||
"${AGENTCI_OPENCODE_VERSION:-^1}"
|
||||
)
|
||||
assert all("no_cache" not in line for line in build)
|
||||
|
||||
dependency = _section(_section(agentci, "depends_on"), "opencode", indent=6)
|
||||
assert _mapping([" dependency:", *dependency], "dependency", indent=6) == {
|
||||
"condition": "service_healthy"
|
||||
}
|
||||
healthcheck = _mapping(opencode, "healthcheck")
|
||||
health_command = json.loads(healthcheck["test"])
|
||||
assert health_command[0] == "CMD-SHELL"
|
||||
assert "/global/health" in health_command[1]
|
||||
assert "OPENCODE_SERVER_PASSWORD_FILE" in health_command[1]
|
||||
|
||||
|
||||
def test_compose_has_no_sandbox_security_exceptions() -> None:
|
||||
compose = (ROOT / "compose.yaml").read_text()
|
||||
|
||||
for forbidden in (
|
||||
"cap_add:",
|
||||
"security_opt:",
|
||||
"privileged:",
|
||||
"seccomp=unconfined",
|
||||
"apparmor=unconfined",
|
||||
"bubblewrap",
|
||||
):
|
||||
assert forbidden not in compose
|
||||
assert "no_cache" not in compose
|
||||
assert "opencode_home:/var/lib/opencode" in compose
|
||||
assert "HOME: /etc/opencode/home" in compose
|
||||
assert "OPENCODE_DISABLE_EXTERNAL_SKILLS" in compose
|
||||
assert "OPENCODE_DISABLE_DEFAULT_PLUGINS" not in compose
|
||||
assert 'OPENCODE_ENABLE_EXA: "1"' in compose
|
||||
assert "AGENTCI_EXPLORE_VARIANT: ${AGENTCI_EXPLORE_VARIANT:-low}" in compose
|
||||
assert "AGENTCI_RESEARCH_VARIANT: ${AGENTCI_RESEARCH_VARIANT:-high}" in compose
|
||||
assert compose.count("/run/agentci:mode=1777") == 2
|
||||
assert "AGENTCI_OPENCODE_VERSION: ${AGENTCI_OPENCODE_VERSION:-^1}" in compose
|
||||
assert "ARG AGENTCI_OPENCODE_VERSION=^1" in dockerfile
|
||||
assert '"opencode-ai@${AGENTCI_OPENCODE_VERSION}"' in dockerfile
|
||||
|
||||
|
||||
def test_container_pins_opencode_major_version_contract() -> None:
|
||||
lines = [line.strip() for line in (ROOT / "Dockerfile").read_text().splitlines()]
|
||||
build_arguments = {line.removeprefix("ARG ") for line in lines if line.startswith("ARG ")}
|
||||
|
||||
assert "AGENTCI_OPENCODE_VERSION=^1" in build_arguments
|
||||
assert any(
|
||||
line.rstrip("\\").strip() == '"opencode-ai@${AGENTCI_OPENCODE_VERSION}"' for line in lines
|
||||
)
|
||||
|
||||
|
||||
def test_compose_document_validates_when_compose_cli_is_available() -> None:
|
||||
docker = shutil.which("docker")
|
||||
if docker is None:
|
||||
pytest.skip("Docker Compose CLI is not installed")
|
||||
probe = subprocess.run(
|
||||
[docker, "compose", "version"],
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if probe.returncode:
|
||||
pytest.skip("Docker Compose provider is not available")
|
||||
|
||||
result = subprocess.run(
|
||||
[docker, "compose", "config", "--quiet"],
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from agentci.adapters.opencode_support import api_contract_ready, models_ready
|
||||
import pytest
|
||||
|
||||
from agentci.opencode import api_contract_ready, models_ready
|
||||
|
||||
|
||||
def test_api_contract_requires_session_message_and_abort_routes() -> None:
|
||||
@@ -17,7 +19,124 @@ def test_api_contract_requires_session_message_and_abort_routes() -> None:
|
||||
assert not api_contract_ready(valid)
|
||||
|
||||
|
||||
def test_model_readiness_requires_tools_and_configured_variant() -> None:
|
||||
@pytest.mark.parametrize(
|
||||
"document",
|
||||
[
|
||||
None,
|
||||
[],
|
||||
{},
|
||||
{"paths": []},
|
||||
{
|
||||
"paths": {
|
||||
"/global/health": None,
|
||||
"/provider": {"get": {}},
|
||||
"/session": {"post": {}},
|
||||
}
|
||||
},
|
||||
{
|
||||
"paths": {
|
||||
"/global/health": {"get": {}},
|
||||
"/provider": {"get": {}},
|
||||
"/session": {"post": {}},
|
||||
7: {"post": {}},
|
||||
}
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_api_contract_rejects_malformed_documents(document: object) -> None:
|
||||
assert not api_contract_ready(document)
|
||||
|
||||
|
||||
def provider_payload(
|
||||
*,
|
||||
connected: object = None,
|
||||
status: str = "active",
|
||||
toolcall: object = True,
|
||||
variants: object = None,
|
||||
) -> dict[str, object]:
|
||||
selected_connected = ["openai"] if connected is None else connected
|
||||
selected_variants = {"high": {}} if variants is None else variants
|
||||
return {
|
||||
"connected": selected_connected,
|
||||
"all": [
|
||||
{
|
||||
"id": "openai",
|
||||
"models": {
|
||||
"model": {
|
||||
"status": status,
|
||||
"capabilities": {"toolcall": toolcall},
|
||||
"variants": selected_variants,
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("payload", "requirement", "expected"),
|
||||
[
|
||||
(provider_payload(), ("openai", "model", "high"), True),
|
||||
(provider_payload(), ("openai", "model", None), True),
|
||||
(provider_payload(connected=("anthropic",)), ("openai", "model", "high"), False),
|
||||
(provider_payload(status="deprecated"), ("openai", "model", "high"), False),
|
||||
(provider_payload(toolcall=False), ("openai", "model", "high"), False),
|
||||
(provider_payload(variants={}), ("openai", "model", "high"), False),
|
||||
(provider_payload(), ("openai", "missing", "high"), False),
|
||||
],
|
||||
)
|
||||
def test_model_readiness_matrix(
|
||||
payload: object, requirement: tuple[str, str, str | None], expected: bool
|
||||
) -> None:
|
||||
assert models_ready(payload, {requirement}) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
None,
|
||||
[],
|
||||
{},
|
||||
{"connected": None, "all": []},
|
||||
{"connected": ["openai"], "all": None},
|
||||
{"connected": ["openai"], "all": [{"id": [], "models": {}}]},
|
||||
{
|
||||
"connected": ["openai"],
|
||||
"all": [
|
||||
{
|
||||
"id": "openai",
|
||||
"models": {
|
||||
"model": {
|
||||
"status": "active",
|
||||
"capabilities": None,
|
||||
"variants": {"high": {}},
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"connected": ["openai"],
|
||||
"all": [
|
||||
{
|
||||
"id": "openai",
|
||||
"models": {
|
||||
"model": {
|
||||
"status": "active",
|
||||
"capabilities": {"toolcall": True},
|
||||
"variants": None,
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_model_readiness_rejects_malformed_payloads(payload: object) -> None:
|
||||
assert not models_ready(payload, {("openai", "model", "high")})
|
||||
|
||||
|
||||
def test_model_readiness_requires_all_configured_models() -> None:
|
||||
payload = {
|
||||
"connected": ["openai"],
|
||||
"all": [
|
||||
@@ -34,7 +153,10 @@ def test_model_readiness_requires_tools_and_configured_variant() -> None:
|
||||
],
|
||||
}
|
||||
|
||||
assert models_ready(payload, {("openai", "model", "high")})
|
||||
assert not models_ready(payload, {("openai", "model", "missing")})
|
||||
payload["all"][0]["models"]["model"]["capabilities"]["toolcall"] = False
|
||||
assert not models_ready(payload, {("openai", "model", "high")})
|
||||
assert not models_ready(
|
||||
payload,
|
||||
{
|
||||
("openai", "model", "high"),
|
||||
("openai", "other", None),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
from pathlib import Path
|
||||
from string import Template
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
import agentci.prompts
|
||||
from agentci.opencode import load_schema
|
||||
from agentci.prompts import PromptLibrary
|
||||
from agentci.workflows.model import (
|
||||
AgentResult,
|
||||
DiscussionReply,
|
||||
PlanArtifact,
|
||||
ReviewFinding,
|
||||
ReviewReport,
|
||||
)
|
||||
|
||||
PROMPTS = {
|
||||
"plan_initial": {"context", "request"},
|
||||
"plan_review": {"context", "artifact"},
|
||||
"plan_revision": {"artifact", "review"},
|
||||
"discuss": {"artifact", "message"},
|
||||
"plan_iterate": {"context", "artifact", "review", "message"},
|
||||
"implement_initial": {
|
||||
"context",
|
||||
"artifact",
|
||||
"request",
|
||||
"development_environment",
|
||||
},
|
||||
"implementation_review": {"issue_context", "artifact", "pull_context"},
|
||||
"implementation_revision": {"review", "development_environment"},
|
||||
"implementation_iterate": {
|
||||
"context",
|
||||
"review",
|
||||
"message",
|
||||
"development_environment",
|
||||
},
|
||||
"fix": {"context", "message", "development_environment"},
|
||||
}
|
||||
|
||||
SCHEMAS: dict[str, tuple[type[BaseModel], set[str]]] = {
|
||||
"plan.json": (PlanArtifact, {"plan_markdown"}),
|
||||
"discussion.json": (DiscussionReply, {"markdown"}),
|
||||
"agent_result.json": (AgentResult, {"summary_markdown", "tests"}),
|
||||
"review.json": (ReviewReport, {"summary", "findings"}),
|
||||
}
|
||||
|
||||
|
||||
def prompt_directory() -> Path:
|
||||
module_file = agentci.prompts.__file__
|
||||
assert module_file is not None
|
||||
return Path(module_file).parent
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("name", "identifiers"), PROMPTS.items())
|
||||
def test_referenced_prompt_loads_and_renders(name: str, identifiers: set[str]) -> None:
|
||||
directory = prompt_directory()
|
||||
source = (directory / f"{name}.md").read_text()
|
||||
values = {identifier: f"<{identifier}-value>" for identifier in identifiers}
|
||||
|
||||
assert set(Template(source).get_identifiers()) == identifiers
|
||||
rendered = PromptLibrary(directory).render(name, **values)
|
||||
for value in values.values():
|
||||
assert value in rendered
|
||||
|
||||
|
||||
def test_prompt_resource_set_matches_workflow_references() -> None:
|
||||
names = {path.stem for path in prompt_directory().glob("*.md")}
|
||||
|
||||
assert names == set(PROMPTS)
|
||||
|
||||
|
||||
def test_prompt_render_rejects_missing_template_value() -> None:
|
||||
with pytest.raises(KeyError, match="request"):
|
||||
PromptLibrary(prompt_directory()).render("plan_initial", context="context")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("name", "contract"), SCHEMAS.items())
|
||||
def test_referenced_schema_matches_result_model(
|
||||
name: str, contract: tuple[type[BaseModel], set[str]]
|
||||
) -> None:
|
||||
model, required = contract
|
||||
schema = load_schema(prompt_directory() / "schemas", name)
|
||||
|
||||
assert schema["type"] == "object"
|
||||
assert schema["additionalProperties"] is False
|
||||
assert set(schema["properties"]) == set(model.model_fields)
|
||||
assert set(schema["required"]) == required
|
||||
|
||||
|
||||
def test_review_finding_schema_matches_model_and_severity_values() -> None:
|
||||
schema = load_schema(prompt_directory() / "schemas", "review.json")
|
||||
finding = schema["properties"]["findings"]["items"]
|
||||
|
||||
assert set(finding["properties"]) == set(ReviewFinding.model_fields)
|
||||
assert set(finding["required"]) == set(ReviewFinding.model_fields)
|
||||
assert finding["properties"]["severity"]["enum"] == [
|
||||
"blocking",
|
||||
"major",
|
||||
"minor",
|
||||
]
|
||||
assert finding["additionalProperties"] is False
|
||||
|
||||
|
||||
def test_schema_resource_set_matches_workflow_references() -> None:
|
||||
schema_directory = prompt_directory() / "schemas"
|
||||
names = {path.name for path in schema_directory.glob("*.json")}
|
||||
|
||||
assert names == set(SCHEMAS)
|
||||
@@ -0,0 +1,414 @@
|
||||
from dataclasses import FrozenInstanceError, replace
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from agentci.engine.events import (
|
||||
CommandReceived,
|
||||
CommentLinked,
|
||||
JobCompleted,
|
||||
JobFailed,
|
||||
JobProgress,
|
||||
JobRejected,
|
||||
JobStarted,
|
||||
PermissionDenied,
|
||||
PermissionGranted,
|
||||
RuntimeSessionLinked,
|
||||
ServiceRestarted,
|
||||
WorkflowCreated,
|
||||
WorkflowLinked,
|
||||
)
|
||||
from agentci.engine.model import (
|
||||
Job,
|
||||
JobKind,
|
||||
JobStatus,
|
||||
QueueName,
|
||||
TaskKind,
|
||||
TaskRequest,
|
||||
Workflow,
|
||||
WorkflowKind,
|
||||
)
|
||||
from agentci.engine.reducer import InvalidTransition, Transition, reduce_job, render_job_comment
|
||||
|
||||
|
||||
def received(body: str = "/agent plan message", *, pr_number: int | None = None) -> Job:
|
||||
return reduce_job(
|
||||
None,
|
||||
CommandReceived(
|
||||
job_id="job",
|
||||
delivery_id="delivery",
|
||||
receive_sequence=1,
|
||||
command_body=body,
|
||||
target_key="org/repo:pr:8" if pr_number else "org/repo:issue:1",
|
||||
repo_owner="org",
|
||||
repo_name="repo",
|
||||
issue_number=1,
|
||||
pr_number=pr_number,
|
||||
requester="alice",
|
||||
comment_id=4,
|
||||
),
|
||||
).job
|
||||
|
||||
|
||||
def queued(body: str = "/agent plan message", *, pr_number: int | None = None) -> Job:
|
||||
state = received(body, pr_number=pr_number)
|
||||
return reduce_job(state, PermissionGranted(job_id=state.id)).job
|
||||
|
||||
|
||||
def running(*, workflow_id: str | None = None) -> Job:
|
||||
state = queued()
|
||||
state = reduce_job(state, JobStarted(job_id=state.id)).job
|
||||
return replace(state, workflow_id=workflow_id)
|
||||
|
||||
|
||||
def task_order(transition: Transition) -> list[tuple[TaskKind, QueueName]]:
|
||||
return [(task.kind, task.queue) for task in transition.tasks]
|
||||
|
||||
|
||||
def test_command_received_preserves_input_and_requests_authorization() -> None:
|
||||
transition = reduce_job(
|
||||
None,
|
||||
CommandReceived(
|
||||
job_id="job-7",
|
||||
delivery_id="delivery-7",
|
||||
receive_sequence=7,
|
||||
command_body="/agent fix race",
|
||||
target_key="org/repo:pr:8",
|
||||
repo_owner="org",
|
||||
repo_name="repo",
|
||||
issue_number=1,
|
||||
pr_number=8,
|
||||
requester="alice",
|
||||
comment_id=4,
|
||||
),
|
||||
)
|
||||
|
||||
assert transition == Transition(
|
||||
Job(
|
||||
id="job-7",
|
||||
target_key="org/repo:pr:8",
|
||||
repo_owner="org",
|
||||
repo_name="repo",
|
||||
issue_number=1,
|
||||
pr_number=8,
|
||||
requester="alice",
|
||||
comment_id=4,
|
||||
delivery_id="delivery-7",
|
||||
receive_sequence=7,
|
||||
command_body="/agent fix race",
|
||||
),
|
||||
(TaskRequest(TaskKind.AUTHORIZE, QueueName.CONTROL),),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("body", "pr_number", "expected_kind", "expected_message"),
|
||||
[
|
||||
("/agent plan write tests", None, JobKind.PLAN, "write tests"),
|
||||
("/agent iterate refine", None, JobKind.ITERATE_PLAN, "refine"),
|
||||
("/agent iterate address review", 8, JobKind.ITERATE_IMPLEMENT, "address review"),
|
||||
],
|
||||
)
|
||||
def test_permission_granted_queues_the_resolved_command(
|
||||
body: str,
|
||||
pr_number: int | None,
|
||||
expected_kind: JobKind,
|
||||
expected_message: str,
|
||||
) -> None:
|
||||
transition = reduce_job(
|
||||
received(body, pr_number=pr_number),
|
||||
PermissionGranted(job_id="job"),
|
||||
)
|
||||
|
||||
assert (
|
||||
transition.job.status,
|
||||
transition.job.stage,
|
||||
transition.job.kind,
|
||||
transition.job.message,
|
||||
task_order(transition),
|
||||
) == (
|
||||
JobStatus.QUEUED,
|
||||
"queued",
|
||||
expected_kind,
|
||||
expected_message,
|
||||
[
|
||||
(TaskKind.EXECUTE, QueueName.JOBS),
|
||||
(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("event", "expected_error"),
|
||||
[
|
||||
(PermissionDenied(job_id="job"), "repository write permission is required"),
|
||||
(PermissionGranted(job_id="job"), "Unknown agent command"),
|
||||
],
|
||||
)
|
||||
def test_permission_rejection_is_terminal_and_reconciled(
|
||||
event: PermissionDenied | PermissionGranted,
|
||||
expected_error: str,
|
||||
) -> None:
|
||||
state = received("/agent nonsense") if isinstance(event, PermissionGranted) else received()
|
||||
transition = reduce_job(state, event)
|
||||
|
||||
assert (
|
||||
transition.job.status,
|
||||
transition.job.stage,
|
||||
expected_error in (transition.job.error or ""),
|
||||
task_order(transition),
|
||||
) == (
|
||||
JobStatus.REJECTED,
|
||||
"rejected",
|
||||
True,
|
||||
[(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL)],
|
||||
)
|
||||
|
||||
|
||||
def test_start_moves_a_queued_job_to_running_and_reconciles() -> None:
|
||||
transition = reduce_job(queued(), JobStarted(job_id="job"))
|
||||
|
||||
assert (
|
||||
transition.job.status,
|
||||
transition.job.stage,
|
||||
task_order(transition),
|
||||
) == (
|
||||
JobStatus.RUNNING,
|
||||
"starting",
|
||||
[(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL)],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("event", "changes"),
|
||||
[
|
||||
(JobProgress(job_id="job", stage="cloning"), {"stage": "cloning"}),
|
||||
(
|
||||
WorkflowCreated(
|
||||
job_id="job",
|
||||
workflow=Workflow(
|
||||
id="created-workflow",
|
||||
kind=WorkflowKind.PLAN,
|
||||
repo_owner="org",
|
||||
repo_name="repo",
|
||||
issue_number=1,
|
||||
workspace_path=Path("/work/repo"),
|
||||
base_sha="abc",
|
||||
),
|
||||
stage="planning",
|
||||
),
|
||||
{"workflow_id": "created-workflow", "stage": "planning"},
|
||||
),
|
||||
(
|
||||
WorkflowLinked(job_id="job", workflow_id="linked-workflow", stage="discussing"),
|
||||
{"workflow_id": "linked-workflow", "stage": "discussing"},
|
||||
),
|
||||
(
|
||||
RuntimeSessionLinked(job_id="job", session_id="session-1"),
|
||||
{"runtime_session_id": "session-1"},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_running_progress_and_links_update_only_reported_fields(
|
||||
event: JobProgress | WorkflowCreated | WorkflowLinked | RuntimeSessionLinked,
|
||||
changes: dict[str, object],
|
||||
) -> None:
|
||||
state = running()
|
||||
transition = reduce_job(state, event)
|
||||
|
||||
assert transition == Transition(replace(state, **changes))
|
||||
|
||||
|
||||
def test_completion_persists_the_result_and_reconciles() -> None:
|
||||
transition = reduce_job(running(), JobCompleted(job_id="job", comment_body="# Result"))
|
||||
|
||||
assert (
|
||||
transition.job.status,
|
||||
transition.job.stage,
|
||||
transition.job.comment_body,
|
||||
task_order(transition),
|
||||
) == (
|
||||
JobStatus.SUCCEEDED,
|
||||
"completed",
|
||||
"# Result",
|
||||
[(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL)],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("event", "workflow_id", "expected_status", "expected_stage", "expected_error", "tasks"),
|
||||
[
|
||||
(
|
||||
JobFailed(job_id="job", stage="testing", error="tests failed"),
|
||||
None,
|
||||
JobStatus.FAILED,
|
||||
"testing",
|
||||
"tests failed",
|
||||
[(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL)],
|
||||
),
|
||||
(
|
||||
JobFailed(job_id="job", stage="testing", error="tests failed"),
|
||||
"workflow",
|
||||
JobStatus.FAILED,
|
||||
"testing",
|
||||
"tests failed",
|
||||
[
|
||||
(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL),
|
||||
(TaskKind.FAIL_WORKFLOW, QueueName.CONTROL),
|
||||
],
|
||||
),
|
||||
(
|
||||
JobRejected(job_id="job", reason="no plan exists"),
|
||||
"workflow",
|
||||
JobStatus.REJECTED,
|
||||
"rejected",
|
||||
"no plan exists",
|
||||
[
|
||||
(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL),
|
||||
(TaskKind.FAIL_WORKFLOW, QueueName.CONTROL),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_running_failure_and_rejection_are_terminal_with_ordered_cleanup(
|
||||
event: JobFailed | JobRejected,
|
||||
workflow_id: str | None,
|
||||
expected_status: JobStatus,
|
||||
expected_stage: str,
|
||||
expected_error: str,
|
||||
tasks: list[tuple[TaskKind, QueueName]],
|
||||
) -> None:
|
||||
transition = reduce_job(running(workflow_id=workflow_id), event)
|
||||
|
||||
assert (
|
||||
transition.job.status,
|
||||
transition.job.stage,
|
||||
transition.job.error,
|
||||
task_order(transition),
|
||||
) == (expected_status, expected_stage, expected_error, tasks)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("workflow_id", "tasks"),
|
||||
[
|
||||
(
|
||||
None,
|
||||
[
|
||||
(TaskKind.ABORT_SESSIONS, QueueName.CONTROL),
|
||||
(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL),
|
||||
],
|
||||
),
|
||||
(
|
||||
"workflow",
|
||||
[
|
||||
(TaskKind.ABORT_SESSIONS, QueueName.CONTROL),
|
||||
(TaskKind.FAIL_WORKFLOW, QueueName.CONTROL),
|
||||
(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_restart_fails_a_running_job_with_ordered_cleanup(
|
||||
workflow_id: str | None,
|
||||
tasks: list[tuple[TaskKind, QueueName]],
|
||||
) -> None:
|
||||
transition = reduce_job(running(workflow_id=workflow_id), ServiceRestarted(job_id="job"))
|
||||
|
||||
assert (
|
||||
transition.job.status,
|
||||
transition.job.stage,
|
||||
transition.job.error,
|
||||
task_order(transition),
|
||||
) == (
|
||||
JobStatus.FAILED,
|
||||
"interrupted",
|
||||
"Service restarted during an active OpenCode turn",
|
||||
tasks,
|
||||
)
|
||||
|
||||
|
||||
def test_restart_is_a_noop_after_a_job_is_terminal() -> None:
|
||||
completed = reduce_job(running(), JobCompleted(job_id="job", comment_body="ok")).job
|
||||
|
||||
assert reduce_job(completed, ServiceRestarted(job_id="job")) == Transition(completed)
|
||||
|
||||
|
||||
def test_comment_link_is_allowed_after_a_job_is_terminal() -> None:
|
||||
completed = reduce_job(running(), JobCompleted(job_id="job", comment_body="ok")).job
|
||||
|
||||
assert reduce_job(completed, CommentLinked(job_id="job", comment_id=9)).job == replace(
|
||||
completed, accepted_comment_id=9
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("state", "event"),
|
||||
[
|
||||
(received(), JobStarted(job_id="job")),
|
||||
(queued(), PermissionGranted(job_id="job")),
|
||||
(running(), PermissionGranted(job_id="job")),
|
||||
(
|
||||
reduce_job(running(), JobCompleted(job_id="job", comment_body="ok")).job,
|
||||
JobStarted(job_id="job"),
|
||||
),
|
||||
],
|
||||
ids=["received", "queued", "running", "terminal"],
|
||||
)
|
||||
def test_events_invalid_for_the_current_status_are_rejected(
|
||||
state: Job,
|
||||
event: JobStarted | PermissionGranted,
|
||||
) -> None:
|
||||
with pytest.raises(InvalidTransition, match="invalid while job"):
|
||||
reduce_job(state, event)
|
||||
|
||||
|
||||
def test_only_command_received_can_create_state() -> None:
|
||||
with pytest.raises(InvalidTransition, match="Only CommandReceived"):
|
||||
reduce_job(None, PermissionGranted(job_id="job"))
|
||||
|
||||
|
||||
def test_event_job_id_must_match_state() -> None:
|
||||
with pytest.raises(InvalidTransition, match="job ID does not match"):
|
||||
reduce_job(received(), PermissionDenied(job_id="another-job"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("state", "expected"),
|
||||
[
|
||||
(
|
||||
received(),
|
||||
"<!-- agentci:job id=job -->\nAgent job `job` received (`command`; stage: `received`).",
|
||||
),
|
||||
(
|
||||
queued(),
|
||||
"<!-- agentci:job id=job -->\nAgent job `job` queued (`plan`; stage: `queued`).",
|
||||
),
|
||||
(
|
||||
replace(
|
||||
running(),
|
||||
status=JobStatus.SUCCEEDED,
|
||||
stage="completed",
|
||||
comment_body="# Done",
|
||||
),
|
||||
"<!-- agentci:job id=job -->\n# Done",
|
||||
),
|
||||
(
|
||||
replace(received(), status=JobStatus.REJECTED, stage="rejected", error="not allowed"),
|
||||
"<!-- agentci:job id=job -->\nAgent job `job` was rejected: not allowed",
|
||||
),
|
||||
(
|
||||
replace(running(), status=JobStatus.FAILED, stage="testing", error="failed"),
|
||||
"<!-- agentci:job id=job -->\nAgent job `job` failed during `testing`: failed",
|
||||
),
|
||||
],
|
||||
ids=["received", "queued", "succeeded", "rejected", "failed"],
|
||||
)
|
||||
def test_render_comment_describes_each_job_outcome(state: Job, expected: str) -> None:
|
||||
assert render_job_comment(state) == expected
|
||||
|
||||
|
||||
def test_job_state_is_immutable() -> None:
|
||||
state = received()
|
||||
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
state.stage = "changed" # type: ignore[misc]
|
||||
@@ -0,0 +1,462 @@
|
||||
import sqlite3
|
||||
from contextlib import closing
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
from agentci.engine import _sqlite
|
||||
from agentci.engine.events import (
|
||||
CommentLinked,
|
||||
JobCompleted,
|
||||
JobStarted,
|
||||
PermissionGranted,
|
||||
WorkflowCreated,
|
||||
)
|
||||
from agentci.engine.model import (
|
||||
IncomingCommand,
|
||||
Job,
|
||||
QueueName,
|
||||
TaskKind,
|
||||
TaskRequest,
|
||||
Workflow,
|
||||
WorkflowKind,
|
||||
WorkflowStatus,
|
||||
)
|
||||
from agentci.engine.reducer import Transition
|
||||
from agentci.engine.repository import Repository
|
||||
|
||||
MIGRATIONS = Path(__file__).parents[1] / "src" / "agentci" / "migrations"
|
||||
|
||||
|
||||
class TrackingConnection:
|
||||
def __init__(self) -> None:
|
||||
self.closed = False
|
||||
|
||||
def __enter__(self) -> "TrackingConnection":
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args: object) -> None:
|
||||
return None
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class FailingSetupConnection:
|
||||
def __init__(self) -> None:
|
||||
self.closed = False
|
||||
self.row_factory: object | None = None
|
||||
|
||||
def execute(self, _statement: str) -> None:
|
||||
raise sqlite3.OperationalError("pragma failed")
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def repository(tmp_path: Path) -> Repository:
|
||||
value = Repository(tmp_path / "state.sqlite3", MIGRATIONS)
|
||||
await value.initialize()
|
||||
return value
|
||||
|
||||
|
||||
def command(
|
||||
delivery: str,
|
||||
body: str = "/agent plan",
|
||||
*,
|
||||
issue: int = 3,
|
||||
pr: int | None = None,
|
||||
) -> IncomingCommand:
|
||||
return IncomingCommand(
|
||||
delivery_id=delivery,
|
||||
comment_id=int(delivery.rsplit("-", 1)[-1]),
|
||||
repo_owner="alice",
|
||||
repo_name="repo",
|
||||
issue_number=issue,
|
||||
pr_number=pr,
|
||||
requester="alice",
|
||||
body=body,
|
||||
)
|
||||
|
||||
|
||||
async def create_running_job(repository: Repository, delivery: str, *, issue: int = 3) -> Job:
|
||||
job = (await repository.accept(command(delivery, issue=issue))).job
|
||||
job = (await repository.apply(f"{delivery}:grant", PermissionGranted(job_id=job.id))).job
|
||||
return (await repository.apply(f"{delivery}:start", JobStarted(job_id=job.id))).job
|
||||
|
||||
|
||||
async def test_repository_closes_connections_after_success_and_failure(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
connections = [TrackingConnection(), TrackingConnection()]
|
||||
monkeypatch.setattr(
|
||||
_sqlite,
|
||||
"connect",
|
||||
lambda _path: cast(sqlite3.Connection, connections.pop(0)),
|
||||
)
|
||||
repository = Repository(tmp_path / "state.sqlite3", MIGRATIONS)
|
||||
successful = cast(TrackingConnection, await repository._run(lambda connection: connection))
|
||||
|
||||
def fail(_connection: sqlite3.Connection) -> None:
|
||||
raise RuntimeError("operation failed")
|
||||
|
||||
failing = connections[0]
|
||||
with pytest.raises(RuntimeError, match="operation failed"):
|
||||
await repository._run(fail)
|
||||
|
||||
assert successful.closed
|
||||
assert failing.closed
|
||||
|
||||
|
||||
def test_sqlite_setup_closes_connection_after_pragma_failure(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
connection = FailingSetupConnection()
|
||||
monkeypatch.setattr(
|
||||
_sqlite.sqlite3,
|
||||
"connect",
|
||||
lambda *_args, **_kwargs: cast(sqlite3.Connection, connection),
|
||||
)
|
||||
|
||||
with pytest.raises(sqlite3.OperationalError, match="pragma failed"):
|
||||
_sqlite.connect(tmp_path / "state.sqlite3")
|
||||
|
||||
assert connection.closed
|
||||
|
||||
|
||||
def build_workflow(
|
||||
workflow_id: str,
|
||||
workspace: Path,
|
||||
*,
|
||||
kind: WorkflowKind = WorkflowKind.PLAN,
|
||||
owner: str = "alice",
|
||||
repo: str = "repo",
|
||||
issue: int = 3,
|
||||
pr: int | None = None,
|
||||
status: WorkflowStatus = WorkflowStatus.ACTIVE,
|
||||
) -> Workflow:
|
||||
return Workflow(
|
||||
id=workflow_id,
|
||||
kind=kind,
|
||||
repo_owner=owner,
|
||||
repo_name=repo,
|
||||
issue_number=issue,
|
||||
pr_number=pr,
|
||||
workspace_path=workspace / workflow_id,
|
||||
base_sha=f"base-{workflow_id}",
|
||||
status=status,
|
||||
)
|
||||
|
||||
|
||||
async def attach_workflow(
|
||||
repository: Repository,
|
||||
workspace: Path,
|
||||
*,
|
||||
delivery: str,
|
||||
workflow_id: str,
|
||||
status: WorkflowStatus = WorkflowStatus.ACTIVE,
|
||||
) -> tuple[Job, Workflow]:
|
||||
job = await create_running_job(repository, delivery)
|
||||
workflow = build_workflow(workflow_id, workspace, status=status)
|
||||
result = await repository.apply(
|
||||
f"{workflow_id}:created",
|
||||
WorkflowCreated(job_id=job.id, workflow=workflow, stage="planning"),
|
||||
)
|
||||
return result.job, workflow
|
||||
|
||||
|
||||
def insert_workflow(
|
||||
connection: sqlite3.Connection,
|
||||
workflow: Workflow,
|
||||
created_at: str,
|
||||
) -> None:
|
||||
connection.execute(
|
||||
"""INSERT INTO workflows(
|
||||
id, kind, repo_owner, repo_name, issue_number, pr_number, base_sha, branch,
|
||||
workspace_path, primary_session_id, reviewer_session_id, artifact, review_json,
|
||||
status, runtime, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
workflow.id,
|
||||
workflow.kind.value,
|
||||
workflow.repo_owner,
|
||||
workflow.repo_name,
|
||||
workflow.issue_number,
|
||||
workflow.pr_number,
|
||||
workflow.base_sha,
|
||||
workflow.branch,
|
||||
str(workflow.workspace_path),
|
||||
workflow.primary_session_id,
|
||||
workflow.reviewer_session_id,
|
||||
workflow.artifact,
|
||||
workflow.review_json,
|
||||
workflow.status.value,
|
||||
workflow.runtime,
|
||||
created_at,
|
||||
created_at,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def test_receive_is_idempotent_without_consuming_sequence(
|
||||
repository: Repository,
|
||||
) -> None:
|
||||
first = await repository.accept(command("delivery-1"))
|
||||
duplicate = await repository.accept(command("delivery-1"))
|
||||
second = await repository.accept(command("delivery-2"))
|
||||
|
||||
assert (
|
||||
first.duplicate,
|
||||
duplicate.duplicate,
|
||||
duplicate.job.id,
|
||||
second.job.receive_sequence,
|
||||
) == (False, True, first.job.id, first.job.receive_sequence + 1)
|
||||
|
||||
|
||||
async def test_repository_stamps_start_and_completion_once(
|
||||
repository: Repository,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
clock = {"now": "2026-02-01T00:00:00+00:00"}
|
||||
monkeypatch.setattr(_sqlite, "now", lambda: clock["now"])
|
||||
job = (await repository.accept(command("delivery-1"))).job
|
||||
job = (await repository.apply("grant", PermissionGranted(job_id=job.id))).job
|
||||
clock["now"] = "2026-02-01T00:01:00+00:00"
|
||||
job = (await repository.apply("start", JobStarted(job_id=job.id))).job
|
||||
clock["now"] = "2026-02-01T00:02:00+00:00"
|
||||
job = (await repository.apply("complete", JobCompleted(job_id=job.id, comment_body="done"))).job
|
||||
clock["now"] = "2026-02-01T00:03:00+00:00"
|
||||
await repository.apply("comment", CommentLinked(job_id=job.id, comment_id=99))
|
||||
|
||||
with closing(sqlite3.connect(repository.database_path)) as connection, connection:
|
||||
timestamps = connection.execute(
|
||||
"SELECT started_at, finished_at FROM jobs WHERE id=?", (job.id,)
|
||||
).fetchone()
|
||||
|
||||
assert timestamps == (
|
||||
"2026-02-01T00:01:00+00:00",
|
||||
"2026-02-01T00:02:00+00:00",
|
||||
)
|
||||
|
||||
|
||||
async def test_apply_rolls_back_event_job_workflow_and_task_after_mid_apply_failure(
|
||||
repository: Repository,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
job = await create_running_job(repository, "delivery-1")
|
||||
workflow = build_workflow("workflow-rollback", tmp_path)
|
||||
original_insert_tasks = _sqlite.insert_tasks
|
||||
|
||||
def fail_after_task_write(
|
||||
connection: sqlite3.Connection,
|
||||
event_id: str,
|
||||
transition: Transition,
|
||||
timestamp: str,
|
||||
) -> None:
|
||||
forced_task = TaskRequest(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL)
|
||||
original_insert_tasks(
|
||||
connection,
|
||||
event_id,
|
||||
replace(transition, tasks=(forced_task,)),
|
||||
timestamp,
|
||||
)
|
||||
raise RuntimeError("injected task persistence failure")
|
||||
|
||||
monkeypatch.setattr(_sqlite, "insert_tasks", fail_after_task_write)
|
||||
|
||||
with pytest.raises(RuntimeError, match="injected task persistence failure"):
|
||||
await repository.apply(
|
||||
"workflow-rollback:created",
|
||||
WorkflowCreated(job_id=job.id, workflow=workflow, stage="planning"),
|
||||
)
|
||||
|
||||
persisted_job = await repository.get_job(job.id)
|
||||
persisted_workflow = await repository.get_workflow(workflow.id)
|
||||
with closing(sqlite3.connect(repository.database_path)) as connection, connection:
|
||||
event_count = connection.execute(
|
||||
"SELECT COUNT(*) FROM job_events WHERE event_id=?",
|
||||
("workflow-rollback:created",),
|
||||
).fetchone()
|
||||
task_count = connection.execute(
|
||||
"SELECT COUNT(*) FROM listener_tasks WHERE source_event_id=?",
|
||||
("workflow-rollback:created",),
|
||||
).fetchone()
|
||||
|
||||
assert (persisted_job, persisted_workflow, event_count, task_count) == (
|
||||
job,
|
||||
None,
|
||||
(0,),
|
||||
(0,),
|
||||
)
|
||||
|
||||
|
||||
async def test_duplicate_event_application_does_not_duplicate_tasks(
|
||||
repository: Repository,
|
||||
) -> None:
|
||||
job = (await repository.accept(command("delivery-1"))).job
|
||||
applied = await repository.apply("permission", PermissionGranted(job_id=job.id))
|
||||
duplicate = await repository.apply("permission", PermissionGranted(job_id=job.id))
|
||||
|
||||
with closing(sqlite3.connect(repository.database_path)) as connection, connection:
|
||||
tasks = connection.execute(
|
||||
"""SELECT ordinal, listener, queue FROM listener_tasks
|
||||
WHERE source_event_id=? ORDER BY ordinal""",
|
||||
("permission",),
|
||||
).fetchall()
|
||||
|
||||
assert (duplicate.duplicate, duplicate.job, tasks) == (
|
||||
True,
|
||||
applied.job,
|
||||
[(0, "execute", "jobs"), (1, "reconcile_comment", "control")],
|
||||
)
|
||||
|
||||
|
||||
async def test_workflow_creation_and_job_link_commit_together(
|
||||
repository: Repository,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
job, workflow = await attach_workflow(
|
||||
repository,
|
||||
tmp_path,
|
||||
delivery="delivery-1",
|
||||
workflow_id="workflow-atomic",
|
||||
)
|
||||
|
||||
assert (job.workflow_id, await repository.get_workflow(workflow.id)) == (
|
||||
workflow.id,
|
||||
workflow,
|
||||
)
|
||||
|
||||
|
||||
async def test_save_workflow_updates_the_mutable_snapshot_and_timestamp(
|
||||
repository: Repository,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_, workflow = await attach_workflow(
|
||||
repository,
|
||||
tmp_path,
|
||||
delivery="delivery-1",
|
||||
workflow_id="workflow-update",
|
||||
)
|
||||
updated = replace(
|
||||
workflow,
|
||||
pr_number=17,
|
||||
branch="agent/updated",
|
||||
primary_session_id="primary",
|
||||
reviewer_session_id="reviewer",
|
||||
artifact="# Updated plan",
|
||||
review_json='{"verdict":"approved"}',
|
||||
status=WorkflowStatus.COMPLETED,
|
||||
)
|
||||
monkeypatch.setattr(_sqlite, "now", lambda: "2026-02-02T00:00:00+00:00")
|
||||
|
||||
await repository.save_workflow(updated)
|
||||
|
||||
with closing(sqlite3.connect(repository.database_path)) as connection, connection:
|
||||
updated_at = connection.execute(
|
||||
"SELECT updated_at FROM workflows WHERE id=?", (workflow.id,)
|
||||
).fetchone()
|
||||
assert (await repository.get_workflow(workflow.id), updated_at) == (
|
||||
updated,
|
||||
("2026-02-02T00:00:00+00:00",),
|
||||
)
|
||||
|
||||
|
||||
async def test_saving_unknown_workflow_fails(repository: Repository, tmp_path: Path) -> None:
|
||||
workflow = build_workflow("missing", tmp_path)
|
||||
|
||||
with pytest.raises(KeyError, match="Unknown workflow"):
|
||||
await repository.save_workflow(workflow)
|
||||
|
||||
|
||||
async def test_fail_job_workflow_fails_only_active_workflows(
|
||||
repository: Repository,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
active_job, active = await attach_workflow(
|
||||
repository,
|
||||
tmp_path,
|
||||
delivery="delivery-1",
|
||||
workflow_id="active-workflow",
|
||||
)
|
||||
completed_job, completed = await attach_workflow(
|
||||
repository,
|
||||
tmp_path,
|
||||
delivery="delivery-2",
|
||||
workflow_id="completed-workflow",
|
||||
status=WorkflowStatus.COMPLETED,
|
||||
)
|
||||
|
||||
await repository.fail_job_workflow(active_job.id)
|
||||
await repository.fail_job_workflow(completed_job.id)
|
||||
|
||||
assert (
|
||||
(await repository.get_workflow(active.id)).status, # type: ignore[union-attr]
|
||||
(await repository.get_workflow(completed.id)).status, # type: ignore[union-attr]
|
||||
) == (WorkflowStatus.FAILED, WorkflowStatus.COMPLETED)
|
||||
|
||||
|
||||
async def test_latest_workflow_returns_newest_completed_match(
|
||||
repository: Repository,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
older = build_workflow("plan-older", tmp_path, status=WorkflowStatus.COMPLETED)
|
||||
newest = build_workflow("plan-newest", tmp_path, status=WorkflowStatus.COMPLETED)
|
||||
active = build_workflow("plan-active", tmp_path)
|
||||
wrong_issue = build_workflow(
|
||||
"plan-wrong-issue", tmp_path, issue=4, status=WorkflowStatus.COMPLETED
|
||||
)
|
||||
with closing(sqlite3.connect(repository.database_path)) as connection, connection:
|
||||
insert_workflow(connection, older, "2026-01-01T00:00:00+00:00")
|
||||
insert_workflow(connection, newest, "2026-01-02T00:00:00+00:00")
|
||||
insert_workflow(connection, active, "2026-01-03T00:00:00+00:00")
|
||||
insert_workflow(connection, wrong_issue, "2026-01-04T00:00:00+00:00")
|
||||
|
||||
assert await repository.latest_workflow("alice", "repo", 3, WorkflowKind.PLAN) == newest
|
||||
|
||||
|
||||
async def test_workflow_for_pr_returns_newest_implementation_match(
|
||||
repository: Repository,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
older = build_workflow("implementation-older", tmp_path, kind=WorkflowKind.IMPLEMENT, pr=17)
|
||||
newest = build_workflow("implementation-newest", tmp_path, kind=WorkflowKind.IMPLEMENT, pr=17)
|
||||
plan = build_workflow("plan-same-pr", tmp_path, pr=17)
|
||||
wrong_repo = build_workflow(
|
||||
"implementation-wrong-repo",
|
||||
tmp_path,
|
||||
kind=WorkflowKind.IMPLEMENT,
|
||||
repo="another",
|
||||
pr=17,
|
||||
)
|
||||
with closing(sqlite3.connect(repository.database_path)) as connection, connection:
|
||||
insert_workflow(connection, older, "2026-01-01T00:00:00+00:00")
|
||||
insert_workflow(connection, newest, "2026-01-02T00:00:00+00:00")
|
||||
insert_workflow(connection, plan, "2026-01-03T00:00:00+00:00")
|
||||
insert_workflow(connection, wrong_repo, "2026-01-04T00:00:00+00:00")
|
||||
|
||||
assert await repository.workflow_for_pr("alice", "repo", 17) == newest
|
||||
|
||||
|
||||
async def test_implementation_workflows_are_newest_first_and_require_a_pr(
|
||||
repository: Repository,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
older = build_workflow("implementation-older", tmp_path, kind=WorkflowKind.IMPLEMENT, pr=17)
|
||||
newest = build_workflow("implementation-newest", tmp_path, kind=WorkflowKind.IMPLEMENT, pr=18)
|
||||
no_pr = build_workflow("implementation-no-pr", tmp_path, kind=WorkflowKind.IMPLEMENT)
|
||||
plan = build_workflow("plan-with-pr", tmp_path, pr=19)
|
||||
with closing(sqlite3.connect(repository.database_path)) as connection, connection:
|
||||
insert_workflow(connection, older, "2026-01-01T00:00:00+00:00")
|
||||
insert_workflow(connection, newest, "2026-01-02T00:00:00+00:00")
|
||||
insert_workflow(connection, no_pr, "2026-01-03T00:00:00+00:00")
|
||||
insert_workflow(connection, plan, "2026-01-04T00:00:00+00:00")
|
||||
|
||||
assert await repository.implementation_workflows("alice", "repo", 3) == [newest, older]
|
||||
@@ -0,0 +1,123 @@
|
||||
import asyncio
|
||||
import sqlite3
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from agentci.engine.events import PermissionDenied, PermissionGranted
|
||||
from agentci.engine.model import IncomingCommand, QueueName, TaskKind
|
||||
from agentci.engine.repository import Repository
|
||||
|
||||
MIGRATIONS = Path(__file__).parents[1] / "src" / "agentci" / "migrations"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def repository(tmp_path: Path) -> Repository:
|
||||
value = Repository(tmp_path / "state.sqlite3", MIGRATIONS)
|
||||
await value.initialize()
|
||||
return value
|
||||
|
||||
|
||||
def command(delivery: str, *, issue: int = 3) -> IncomingCommand:
|
||||
return IncomingCommand(
|
||||
delivery_id=delivery,
|
||||
comment_id=int(delivery.rsplit("-", 1)[-1]),
|
||||
repo_owner="alice",
|
||||
repo_name="repo",
|
||||
issue_number=issue,
|
||||
pr_number=None,
|
||||
requester="alice",
|
||||
body="/agent plan",
|
||||
)
|
||||
|
||||
|
||||
async def test_concurrent_duplicate_accepts_create_one_job_and_event(
|
||||
repository: Repository,
|
||||
) -> None:
|
||||
results = await asyncio.gather(
|
||||
repository.accept(command("delivery-1")),
|
||||
repository.accept(command("delivery-1")),
|
||||
)
|
||||
|
||||
assert sorted(result.duplicate for result in results) == [False, True]
|
||||
assert len({result.job.id for result in results}) == 1
|
||||
with closing(sqlite3.connect(repository.database_path)) as connection, connection:
|
||||
job_count = connection.execute("SELECT COUNT(*) FROM jobs").fetchone()
|
||||
event_count = connection.execute("SELECT COUNT(*) FROM job_events").fetchone()
|
||||
assert job_count == (1,)
|
||||
assert event_count == (1,)
|
||||
|
||||
second = await repository.accept(command("delivery-2"))
|
||||
assert second.job.receive_sequence == results[0].job.receive_sequence + 1
|
||||
|
||||
|
||||
async def test_received_job_blocks_later_execute_task_for_same_target(
|
||||
repository: Repository,
|
||||
) -> None:
|
||||
first = (await repository.accept(command("delivery-1"))).job
|
||||
second = (await repository.accept(command("delivery-2"))).job
|
||||
await repository.apply("grant-2", PermissionGranted(job_id=second.id))
|
||||
|
||||
assert await repository.claim_task(QueueName.JOBS) is None
|
||||
|
||||
await repository.apply("deny-1", PermissionDenied(job_id=first.id))
|
||||
task = await repository.claim_task(QueueName.JOBS)
|
||||
assert task is not None
|
||||
assert task.job_id == second.id
|
||||
assert task.kind is TaskKind.EXECUTE
|
||||
|
||||
|
||||
async def test_received_job_does_not_block_a_different_target(
|
||||
repository: Repository,
|
||||
) -> None:
|
||||
await repository.accept(command("delivery-1", issue=3))
|
||||
second = (await repository.accept(command("delivery-2", issue=4))).job
|
||||
await repository.apply("grant-2", PermissionGranted(job_id=second.id))
|
||||
|
||||
task = await repository.claim_task(QueueName.JOBS)
|
||||
assert task is not None
|
||||
assert task.job_id == second.id
|
||||
|
||||
|
||||
async def test_claims_multiple_eligible_targets_without_duplicates(
|
||||
repository: Repository,
|
||||
) -> None:
|
||||
first = (await repository.accept(command("delivery-1", issue=3))).job
|
||||
second = (await repository.accept(command("delivery-2", issue=4))).job
|
||||
await repository.apply("grant-1", PermissionGranted(job_id=first.id))
|
||||
await repository.apply("grant-2", PermissionGranted(job_id=second.id))
|
||||
|
||||
claimed = [
|
||||
await repository.claim_task(QueueName.JOBS),
|
||||
await repository.claim_task(QueueName.JOBS),
|
||||
]
|
||||
|
||||
assert [task.job_id for task in claimed if task is not None] == [first.id, second.id]
|
||||
assert await repository.claim_task(QueueName.JOBS) is None
|
||||
|
||||
|
||||
async def test_concurrent_claims_do_not_duplicate_task(repository: Repository) -> None:
|
||||
await repository.accept(command("delivery-1"))
|
||||
|
||||
claims = await asyncio.gather(
|
||||
repository.claim_task(QueueName.CONTROL),
|
||||
repository.claim_task(QueueName.CONTROL),
|
||||
)
|
||||
|
||||
claimed = [task for task in claims if task is not None]
|
||||
assert len(claimed) == 1
|
||||
assert claimed[0].kind is TaskKind.AUTHORIZE
|
||||
assert claimed[0].queue is QueueName.CONTROL
|
||||
assert await repository.claim_task(QueueName.CONTROL) is None
|
||||
|
||||
|
||||
async def test_duplicate_event_id_cannot_be_reused_for_another_job(
|
||||
repository: Repository,
|
||||
) -> None:
|
||||
first = (await repository.accept(command("delivery-1", issue=3))).job
|
||||
second = (await repository.accept(command("delivery-2", issue=4))).job
|
||||
await repository.apply("permission", PermissionGranted(job_id=first.id))
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await repository.apply("permission", PermissionGranted(job_id=second.id))
|
||||
@@ -0,0 +1,413 @@
|
||||
import sqlite3
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from agentci.engine.model import JobKind, JobStatus, Workflow, WorkflowKind, WorkflowStatus
|
||||
from agentci.engine.repository import Repository
|
||||
|
||||
MIGRATIONS = Path(__file__).parents[1] / "src" / "agentci" / "migrations"
|
||||
PRE_V3_CREATED_AT = "2026-01-01T00:00:00+00:00"
|
||||
|
||||
|
||||
def create_schema_v2_database(database_path: Path, workspace: Path) -> None:
|
||||
with closing(sqlite3.connect(database_path)) as connection, connection:
|
||||
connection.executescript((MIGRATIONS / "001_initial.sql").read_text())
|
||||
connection.execute(
|
||||
"INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)",
|
||||
(1, "2025-11-01T00:00:00+00:00"),
|
||||
)
|
||||
connection.executescript((MIGRATIONS / "002_opencode_sessions.sql").read_text())
|
||||
connection.execute(
|
||||
"INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)",
|
||||
(2, "2025-12-01T00:00:00+00:00"),
|
||||
)
|
||||
connection.executemany(
|
||||
"INSERT INTO deliveries(delivery_id, comment_id, received_at) VALUES (?, ?, ?)",
|
||||
[
|
||||
("delivery-queued", 102, "2025-12-31T23:59:58+00:00"),
|
||||
("delivery-unrelated", 999, "2025-12-31T23:59:59+00:00"),
|
||||
],
|
||||
)
|
||||
connection.executemany(
|
||||
"""INSERT INTO workflows(
|
||||
id, kind, repo_owner, repo_name, issue_number, pr_number, base_sha, branch,
|
||||
workspace_path, primary_session_id, reviewer_session_id, artifact, review_json,
|
||||
status, created_at, updated_at, runtime
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
[
|
||||
(
|
||||
"pre-v3-plan-workflow",
|
||||
"plan",
|
||||
"alice",
|
||||
"repo",
|
||||
7,
|
||||
None,
|
||||
"plan-base",
|
||||
"agent/plan",
|
||||
str(workspace / "plan"),
|
||||
"plan-primary",
|
||||
"plan-reviewer",
|
||||
"# Existing plan",
|
||||
'{"verdict":"approved"}',
|
||||
"active",
|
||||
"2025-12-30T00:00:00+00:00",
|
||||
"2025-12-31T00:00:00+00:00",
|
||||
"opencode",
|
||||
),
|
||||
(
|
||||
"pre-v3-implementation-workflow",
|
||||
"implement",
|
||||
"alice",
|
||||
"repo",
|
||||
8,
|
||||
44,
|
||||
"implementation-base",
|
||||
"agent/implementation",
|
||||
str(workspace / "implementation"),
|
||||
"implementation-primary",
|
||||
"implementation-reviewer",
|
||||
"# Existing implementation",
|
||||
'{"verdict":"changes_requested"}',
|
||||
"completed",
|
||||
"2025-12-29T00:00:00+00:00",
|
||||
"2025-12-31T12:00:00+00:00",
|
||||
"codex",
|
||||
),
|
||||
],
|
||||
)
|
||||
connection.executemany(
|
||||
"""INSERT INTO jobs(
|
||||
id, kind, target_key, repo_owner, repo_name, issue_number, pr_number,
|
||||
requester, message, comment_id, workflow_id, status, stage, error,
|
||||
accepted_comment_id, started_comment_id, created_at, started_at, finished_at,
|
||||
runtime_session_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
[
|
||||
(
|
||||
"pre-v3-job-a-terminal",
|
||||
"iterate_implement",
|
||||
"alice/repo:pr:44",
|
||||
"alice",
|
||||
"repo",
|
||||
8,
|
||||
44,
|
||||
"bob",
|
||||
"address review",
|
||||
101,
|
||||
"pre-v3-implementation-workflow",
|
||||
"succeeded",
|
||||
"completed",
|
||||
None,
|
||||
201,
|
||||
202,
|
||||
PRE_V3_CREATED_AT,
|
||||
"2026-01-01T00:01:00+00:00",
|
||||
"2026-01-01T00:02:00+00:00",
|
||||
"implementation-runtime",
|
||||
),
|
||||
(
|
||||
"pre-v3-job-b-queued",
|
||||
"iterate_plan",
|
||||
"alice/repo:issue:7",
|
||||
"alice",
|
||||
"repo",
|
||||
7,
|
||||
None,
|
||||
"alice",
|
||||
"refine plan",
|
||||
102,
|
||||
"pre-v3-plan-workflow",
|
||||
"queued",
|
||||
"queued",
|
||||
None,
|
||||
203,
|
||||
204,
|
||||
PRE_V3_CREATED_AT,
|
||||
None,
|
||||
None,
|
||||
"plan-runtime",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def migrated_pre_v3_repository(tmp_path: Path) -> Repository:
|
||||
database_path = tmp_path / "schema-v2.sqlite3"
|
||||
create_schema_v2_database(database_path, tmp_path / "workspaces")
|
||||
repository = Repository(database_path, MIGRATIONS)
|
||||
await repository.initialize()
|
||||
return repository
|
||||
|
||||
|
||||
async def test_schema_v2_migration_reconstructs_identity_order_and_iterate_commands(
|
||||
migrated_pre_v3_repository: Repository,
|
||||
) -> None:
|
||||
with (
|
||||
closing(sqlite3.connect(migrated_pre_v3_repository.database_path)) as connection,
|
||||
connection,
|
||||
):
|
||||
migrated = connection.execute(
|
||||
"SELECT id, delivery_id, receive_sequence, command_body FROM jobs "
|
||||
"ORDER BY receive_sequence"
|
||||
).fetchall()
|
||||
|
||||
assert migrated == [
|
||||
(
|
||||
"pre-v3-job-a-terminal",
|
||||
"legacy:pre-v3-job-a-terminal",
|
||||
1,
|
||||
"/agent iterate address review",
|
||||
),
|
||||
("pre-v3-job-b-queued", "delivery-queued", 2, "/agent iterate refine plan"),
|
||||
]
|
||||
|
||||
|
||||
async def test_schema_v2_migration_preserves_pre_v3_job_fields(
|
||||
migrated_pre_v3_repository: Repository,
|
||||
) -> None:
|
||||
terminal = await migrated_pre_v3_repository.get_job("pre-v3-job-a-terminal")
|
||||
queued = await migrated_pre_v3_repository.get_job("pre-v3-job-b-queued")
|
||||
with (
|
||||
closing(sqlite3.connect(migrated_pre_v3_repository.database_path)) as connection,
|
||||
connection,
|
||||
):
|
||||
storage_fields = connection.execute(
|
||||
"""SELECT id, started_comment_id, created_at, started_at, finished_at
|
||||
FROM jobs ORDER BY receive_sequence"""
|
||||
).fetchall()
|
||||
|
||||
assert (
|
||||
terminal
|
||||
and (
|
||||
terminal.kind,
|
||||
terminal.target_key,
|
||||
terminal.repo_owner,
|
||||
terminal.repo_name,
|
||||
terminal.issue_number,
|
||||
terminal.pr_number,
|
||||
terminal.requester,
|
||||
terminal.message,
|
||||
terminal.comment_id,
|
||||
terminal.workflow_id,
|
||||
terminal.status,
|
||||
terminal.stage,
|
||||
terminal.error,
|
||||
terminal.runtime_session_id,
|
||||
terminal.accepted_comment_id,
|
||||
terminal.comment_body,
|
||||
),
|
||||
queued
|
||||
and (
|
||||
queued.kind,
|
||||
queued.target_key,
|
||||
queued.requester,
|
||||
queued.message,
|
||||
queued.comment_id,
|
||||
queued.workflow_id,
|
||||
queued.status,
|
||||
queued.stage,
|
||||
queued.runtime_session_id,
|
||||
queued.accepted_comment_id,
|
||||
),
|
||||
storage_fields,
|
||||
) == (
|
||||
(
|
||||
JobKind.ITERATE_IMPLEMENT,
|
||||
"alice/repo:pr:44",
|
||||
"alice",
|
||||
"repo",
|
||||
8,
|
||||
44,
|
||||
"bob",
|
||||
"address review",
|
||||
101,
|
||||
"pre-v3-implementation-workflow",
|
||||
JobStatus.SUCCEEDED,
|
||||
"completed",
|
||||
None,
|
||||
"implementation-runtime",
|
||||
201,
|
||||
None,
|
||||
),
|
||||
(
|
||||
JobKind.ITERATE_PLAN,
|
||||
"alice/repo:issue:7",
|
||||
"alice",
|
||||
"refine plan",
|
||||
102,
|
||||
"pre-v3-plan-workflow",
|
||||
JobStatus.QUEUED,
|
||||
"queued",
|
||||
"plan-runtime",
|
||||
203,
|
||||
),
|
||||
[
|
||||
(
|
||||
"pre-v3-job-a-terminal",
|
||||
202,
|
||||
PRE_V3_CREATED_AT,
|
||||
"2026-01-01T00:01:00+00:00",
|
||||
"2026-01-01T00:02:00+00:00",
|
||||
),
|
||||
("pre-v3-job-b-queued", 204, PRE_V3_CREATED_AT, None, None),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
async def test_schema_v2_migration_preserves_pre_v3_workflows(
|
||||
migrated_pre_v3_repository: Repository,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
plan = await migrated_pre_v3_repository.get_workflow("pre-v3-plan-workflow")
|
||||
implementation = await migrated_pre_v3_repository.get_workflow("pre-v3-implementation-workflow")
|
||||
with (
|
||||
closing(sqlite3.connect(migrated_pre_v3_repository.database_path)) as connection,
|
||||
connection,
|
||||
):
|
||||
timestamps = connection.execute(
|
||||
"SELECT id, created_at, updated_at FROM workflows ORDER BY id"
|
||||
).fetchall()
|
||||
|
||||
assert ((plan, implementation), timestamps) == (
|
||||
(
|
||||
Workflow(
|
||||
id="pre-v3-plan-workflow",
|
||||
kind=WorkflowKind.PLAN,
|
||||
repo_owner="alice",
|
||||
repo_name="repo",
|
||||
issue_number=7,
|
||||
workspace_path=tmp_path / "workspaces" / "plan",
|
||||
base_sha="plan-base",
|
||||
runtime="opencode",
|
||||
branch="agent/plan",
|
||||
primary_session_id="plan-primary",
|
||||
reviewer_session_id="plan-reviewer",
|
||||
artifact="# Existing plan",
|
||||
review_json='{"verdict":"approved"}',
|
||||
status=WorkflowStatus.ACTIVE,
|
||||
),
|
||||
Workflow(
|
||||
id="pre-v3-implementation-workflow",
|
||||
kind=WorkflowKind.IMPLEMENT,
|
||||
repo_owner="alice",
|
||||
repo_name="repo",
|
||||
issue_number=8,
|
||||
pr_number=44,
|
||||
workspace_path=tmp_path / "workspaces" / "implementation",
|
||||
base_sha="implementation-base",
|
||||
runtime="codex",
|
||||
branch="agent/implementation",
|
||||
primary_session_id="implementation-primary",
|
||||
reviewer_session_id="implementation-reviewer",
|
||||
artifact="# Existing implementation",
|
||||
review_json='{"verdict":"changes_requested"}',
|
||||
status=WorkflowStatus.COMPLETED,
|
||||
),
|
||||
),
|
||||
[
|
||||
(
|
||||
"pre-v3-implementation-workflow",
|
||||
"2025-12-29T00:00:00+00:00",
|
||||
"2025-12-31T12:00:00+00:00",
|
||||
),
|
||||
(
|
||||
"pre-v3-plan-workflow",
|
||||
"2025-12-30T00:00:00+00:00",
|
||||
"2025-12-31T00:00:00+00:00",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
async def test_schema_v2_migration_creates_audit_events_and_only_queued_tasks(
|
||||
migrated_pre_v3_repository: Repository,
|
||||
) -> None:
|
||||
with (
|
||||
closing(sqlite3.connect(migrated_pre_v3_repository.database_path)) as connection,
|
||||
connection,
|
||||
):
|
||||
events = connection.execute(
|
||||
"""SELECT event_id, job_id, event_type, payload_json, created_at
|
||||
FROM job_events ORDER BY job_id"""
|
||||
).fetchall()
|
||||
tasks = connection.execute(
|
||||
"""SELECT job_id, source_event_id, ordinal, listener, queue, status,
|
||||
available_at, created_at
|
||||
FROM listener_tasks ORDER BY ordinal"""
|
||||
).fetchall()
|
||||
|
||||
assert (events, tasks) == (
|
||||
[
|
||||
(
|
||||
"delivery:legacy:pre-v3-job-a-terminal",
|
||||
"pre-v3-job-a-terminal",
|
||||
"legacy",
|
||||
"{}",
|
||||
PRE_V3_CREATED_AT,
|
||||
),
|
||||
(
|
||||
"delivery:delivery-queued",
|
||||
"pre-v3-job-b-queued",
|
||||
"legacy",
|
||||
"{}",
|
||||
PRE_V3_CREATED_AT,
|
||||
),
|
||||
],
|
||||
[
|
||||
(
|
||||
"pre-v3-job-b-queued",
|
||||
"delivery:delivery-queued",
|
||||
0,
|
||||
"execute",
|
||||
"jobs",
|
||||
"pending",
|
||||
PRE_V3_CREATED_AT,
|
||||
PRE_V3_CREATED_AT,
|
||||
),
|
||||
(
|
||||
"pre-v3-job-b-queued",
|
||||
"delivery:delivery-queued",
|
||||
1,
|
||||
"reconcile_comment",
|
||||
"control",
|
||||
"pending",
|
||||
PRE_V3_CREATED_AT,
|
||||
PRE_V3_CREATED_AT,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def durable_snapshot(database_path: Path) -> tuple[object, ...]:
|
||||
with closing(sqlite3.connect(database_path)) as connection, connection:
|
||||
return (
|
||||
connection.execute("SELECT version FROM schema_migrations ORDER BY version").fetchall(),
|
||||
connection.execute(
|
||||
"SELECT id, delivery_id, receive_sequence, command_body FROM jobs ORDER BY id"
|
||||
).fetchall(),
|
||||
connection.execute(
|
||||
"""SELECT event_id, job_id, event_type, payload_json
|
||||
FROM job_events ORDER BY event_id"""
|
||||
).fetchall(),
|
||||
connection.execute(
|
||||
"""SELECT job_id, source_event_id, ordinal, listener, queue, status
|
||||
FROM listener_tasks ORDER BY id"""
|
||||
).fetchall(),
|
||||
)
|
||||
|
||||
|
||||
async def test_reopening_migrated_schema_v2_database_is_idempotent(
|
||||
migrated_pre_v3_repository: Repository,
|
||||
) -> None:
|
||||
before = durable_snapshot(migrated_pre_v3_repository.database_path)
|
||||
reopened = Repository(migrated_pre_v3_repository.database_path, MIGRATIONS)
|
||||
|
||||
await reopened.initialize()
|
||||
|
||||
assert (before[0], durable_snapshot(reopened.database_path)) == (
|
||||
[(1,), (2,), (3,)],
|
||||
before,
|
||||
)
|
||||
@@ -0,0 +1,193 @@
|
||||
import sqlite3
|
||||
from contextlib import closing
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from agentci.engine import _sqlite
|
||||
from agentci.engine import repository as repository_module
|
||||
from agentci.engine.events import JobStarted, PermissionGranted
|
||||
from agentci.engine.model import IncomingCommand, QueueName, Task, TaskKind
|
||||
from agentci.engine.repository import Repository
|
||||
|
||||
MIGRATIONS = Path(__file__).parents[1] / "src" / "agentci" / "migrations"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def repository(tmp_path: Path) -> Repository:
|
||||
value = Repository(tmp_path / "state.sqlite3", MIGRATIONS)
|
||||
await value.initialize()
|
||||
return value
|
||||
|
||||
|
||||
def command(delivery: str, *, issue: int = 3) -> IncomingCommand:
|
||||
return IncomingCommand(
|
||||
delivery_id=delivery,
|
||||
comment_id=int(delivery.rsplit("-", 1)[-1]),
|
||||
repo_owner="alice",
|
||||
repo_name="repo",
|
||||
issue_number=issue,
|
||||
pr_number=None,
|
||||
requester="alice",
|
||||
body="/agent plan",
|
||||
)
|
||||
|
||||
|
||||
def task_storage(repository: Repository, task_id: int) -> tuple[object, ...]:
|
||||
with closing(sqlite3.connect(repository.database_path)) as connection, connection:
|
||||
row = connection.execute(
|
||||
"""SELECT status, attempts, available_at, error, created_at, started_at, finished_at
|
||||
FROM listener_tasks WHERE id=?""",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
return row
|
||||
|
||||
|
||||
async def test_claim_and_complete_record_attempt_and_lifecycle_timestamps(
|
||||
repository: Repository,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
clock = {"now": "2026-02-01T00:00:00+00:00"}
|
||||
monkeypatch.setattr(_sqlite, "now", lambda: clock["now"])
|
||||
job = (await repository.accept(command("delivery-1"))).job
|
||||
clock["now"] = "2026-02-01T00:01:00+00:00"
|
||||
|
||||
task = await repository.claim_task(QueueName.CONTROL)
|
||||
assert task is not None
|
||||
clock["now"] = "2026-02-01T00:02:00+00:00"
|
||||
await repository.complete_task(task.id)
|
||||
|
||||
assert (task, task_storage(repository, task.id)) == (
|
||||
Task(
|
||||
id=task.id,
|
||||
job_id=job.id,
|
||||
source_event_id="delivery:delivery-1",
|
||||
kind=TaskKind.AUTHORIZE,
|
||||
queue=QueueName.CONTROL,
|
||||
attempts=1,
|
||||
),
|
||||
(
|
||||
"completed",
|
||||
1,
|
||||
"2026-02-01T00:00:00+00:00",
|
||||
None,
|
||||
"2026-02-01T00:00:00+00:00",
|
||||
"2026-02-01T00:01:00+00:00",
|
||||
"2026-02-01T00:02:00+00:00",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("attempts", "delay_seconds"), [(1, 2), (9, 256)])
|
||||
async def test_retry_uses_bounded_backoff_and_preserves_attempt_history_until_due(
|
||||
repository: Repository,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
attempts: int,
|
||||
delay_seconds: int,
|
||||
) -> None:
|
||||
clock = {"now": "2026-02-01T00:00:00+00:00"}
|
||||
monkeypatch.setattr(_sqlite, "now", lambda: clock["now"])
|
||||
await repository.accept(command("delivery-1"))
|
||||
task = await repository.claim_task(QueueName.CONTROL)
|
||||
assert task is not None
|
||||
with closing(sqlite3.connect(repository.database_path)) as connection, connection:
|
||||
connection.execute("UPDATE listener_tasks SET attempts=? WHERE id=?", (attempts, task.id))
|
||||
|
||||
retry_time = datetime(2026, 2, 1, 1, tzinfo=UTC)
|
||||
available_at = retry_time + timedelta(seconds=delay_seconds)
|
||||
|
||||
class FixedDateTime:
|
||||
@staticmethod
|
||||
def now(_timezone: object) -> datetime:
|
||||
return retry_time
|
||||
|
||||
monkeypatch.setattr(repository_module, "datetime", FixedDateTime)
|
||||
error = "failure: " + "x" * 1100
|
||||
await repository.retry_task(task.id, attempts, error)
|
||||
pending_storage = task_storage(repository, task.id)
|
||||
clock["now"] = (available_at - timedelta(microseconds=1)).isoformat()
|
||||
early_claim = await repository.claim_task(QueueName.CONTROL)
|
||||
clock["now"] = available_at.isoformat()
|
||||
due_claim = await repository.claim_task(QueueName.CONTROL)
|
||||
|
||||
assert (
|
||||
pending_storage,
|
||||
early_claim,
|
||||
due_claim and due_claim.attempts,
|
||||
) == (
|
||||
(
|
||||
"pending",
|
||||
attempts,
|
||||
available_at.isoformat(),
|
||||
error[:1000],
|
||||
"2026-02-01T00:00:00+00:00",
|
||||
"2026-02-01T00:00:00+00:00",
|
||||
None,
|
||||
),
|
||||
None,
|
||||
attempts + 1,
|
||||
)
|
||||
|
||||
|
||||
async def test_recovery_requeues_control_and_unstarted_execution_but_fails_started_execution(
|
||||
repository: Repository,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
clock = {"now": "2026-02-01T00:00:00+00:00"}
|
||||
monkeypatch.setattr(_sqlite, "now", lambda: clock["now"])
|
||||
|
||||
await repository.accept(command("delivery-1", issue=1))
|
||||
control_task = await repository.claim_task(QueueName.CONTROL)
|
||||
assert control_task is not None
|
||||
|
||||
queued_job = (await repository.accept(command("delivery-2", issue=2))).job
|
||||
await repository.apply("queued:grant", PermissionGranted(job_id=queued_job.id))
|
||||
queued_execute = await repository.claim_task(QueueName.JOBS)
|
||||
assert queued_execute is not None
|
||||
|
||||
running_job = (await repository.accept(command("delivery-3", issue=3))).job
|
||||
await repository.apply("running:grant", PermissionGranted(job_id=running_job.id))
|
||||
running_execute = await repository.claim_task(QueueName.JOBS)
|
||||
assert running_execute is not None
|
||||
await repository.apply("running:start", JobStarted(job_id=running_job.id))
|
||||
|
||||
clock["now"] = "2026-02-01T01:00:00+00:00"
|
||||
await repository.recover_tasks()
|
||||
|
||||
assert (
|
||||
task_storage(repository, control_task.id),
|
||||
task_storage(repository, queued_execute.id),
|
||||
task_storage(repository, running_execute.id),
|
||||
[job.id for job in await repository.running_jobs()],
|
||||
) == (
|
||||
(
|
||||
"pending",
|
||||
1,
|
||||
"2026-02-01T00:00:00+00:00",
|
||||
None,
|
||||
"2026-02-01T00:00:00+00:00",
|
||||
None,
|
||||
None,
|
||||
),
|
||||
(
|
||||
"pending",
|
||||
1,
|
||||
"2026-02-01T00:00:00+00:00",
|
||||
None,
|
||||
"2026-02-01T00:00:00+00:00",
|
||||
None,
|
||||
None,
|
||||
),
|
||||
(
|
||||
"failed",
|
||||
1,
|
||||
"2026-02-01T00:00:00+00:00",
|
||||
"Service restarted after execution began",
|
||||
"2026-02-01T00:00:00+00:00",
|
||||
"2026-02-01T00:00:00+00:00",
|
||||
"2026-02-01T01:00:00+00:00",
|
||||
),
|
||||
[running_job.id],
|
||||
)
|
||||
@@ -0,0 +1,178 @@
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
import agentci.runtime as runtime_module
|
||||
from agentci.config import Settings
|
||||
from agentci.runtime import Runtime
|
||||
|
||||
|
||||
class ClosingClient:
|
||||
def __init__(self, name: str, events: list[str], error: Exception | None = None) -> None:
|
||||
self.name = name
|
||||
self.events = events
|
||||
self.error = error
|
||||
|
||||
async def close(self) -> None:
|
||||
self.events.append(self.name)
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
|
||||
|
||||
def runtime(opencode: object, gitea: object) -> Runtime:
|
||||
return Runtime(
|
||||
settings=SimpleNamespace(), # type: ignore[arg-type]
|
||||
repository=SimpleNamespace(), # type: ignore[arg-type]
|
||||
gitea=gitea, # type: ignore[arg-type]
|
||||
git=SimpleNamespace(), # type: ignore[arg-type]
|
||||
opencode=opencode, # type: ignore[arg-type]
|
||||
worker=SimpleNamespace(), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
async def test_close_releases_provider_clients_in_order() -> None:
|
||||
events: list[str] = []
|
||||
value = runtime(ClosingClient("opencode", events), ClosingClient("gitea", events))
|
||||
|
||||
await value.close()
|
||||
|
||||
assert events == ["opencode", "gitea"]
|
||||
|
||||
|
||||
async def test_close_still_releases_gitea_when_opencode_close_fails() -> None:
|
||||
events: list[str] = []
|
||||
value = runtime(
|
||||
ClosingClient("opencode", events, RuntimeError("opencode close failed")),
|
||||
ClosingClient("gitea", events),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="opencode close failed"):
|
||||
await value.close()
|
||||
|
||||
assert events == ["opencode", "gitea"]
|
||||
|
||||
|
||||
async def test_close_propagates_gitea_close_failure_after_opencode_closes() -> None:
|
||||
events: list[str] = []
|
||||
value = runtime(
|
||||
ClosingClient("opencode", events),
|
||||
ClosingClient("gitea", events, RuntimeError("gitea close failed")),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="gitea close failed"):
|
||||
await value.close()
|
||||
|
||||
assert events == ["opencode", "gitea"]
|
||||
|
||||
|
||||
async def test_build_runtime_wires_components_without_starting_real_clients(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
token_file = tmp_path / "gitea-token"
|
||||
password_file = tmp_path / "opencode-password"
|
||||
token_file.write_text("token\n")
|
||||
password_file.write_text("password\n")
|
||||
settings = Settings(
|
||||
_env_file=None, # type: ignore[call-arg]
|
||||
data_dir=tmp_path / "data",
|
||||
gitea_url="https://gitea.example/",
|
||||
gitea_token_file=token_file,
|
||||
opencode_url="https://opencode.example/",
|
||||
opencode_server_password_file=password_file,
|
||||
install_scripts=["python"],
|
||||
max_concurrent_jobs=4,
|
||||
)
|
||||
initialize = AsyncMock()
|
||||
repository = SimpleNamespace(initialize=initialize)
|
||||
gitea = SimpleNamespace()
|
||||
git = SimpleNamespace()
|
||||
opencode = SimpleNamespace()
|
||||
development = SimpleNamespace()
|
||||
prompts = SimpleNamespace()
|
||||
services = SimpleNamespace()
|
||||
worker = SimpleNamespace()
|
||||
repository_constructor = Mock(return_value=repository)
|
||||
gitea_constructor = Mock(return_value=gitea)
|
||||
git_constructor = Mock(return_value=git)
|
||||
opencode_constructor = Mock(return_value=opencode)
|
||||
development_constructor = Mock(return_value=development)
|
||||
prompt_constructor = Mock(return_value=prompts)
|
||||
services_constructor = Mock(return_value=services)
|
||||
worker_constructor = Mock(return_value=worker)
|
||||
monkeypatch.setattr(runtime_module, "Repository", repository_constructor)
|
||||
monkeypatch.setattr(runtime_module, "Gitea", gitea_constructor)
|
||||
monkeypatch.setattr(runtime_module, "Git", git_constructor)
|
||||
monkeypatch.setattr(runtime_module, "OpenCode", opencode_constructor)
|
||||
monkeypatch.setattr(runtime_module, "DevelopmentEnvironment", development_constructor)
|
||||
monkeypatch.setattr(runtime_module, "PromptLibrary", prompt_constructor)
|
||||
monkeypatch.setattr(runtime_module, "WorkflowServices", services_constructor)
|
||||
monkeypatch.setattr(runtime_module, "Worker", worker_constructor)
|
||||
|
||||
built = await runtime_module.build_runtime(settings)
|
||||
|
||||
assert runtime_module.__file__ is not None
|
||||
package_dir = Path(runtime_module.__file__).parent
|
||||
repository_constructor.assert_called_once_with(
|
||||
settings.database_path, package_dir / "migrations"
|
||||
)
|
||||
initialize.assert_awaited_once_with()
|
||||
gitea_constructor.assert_called_once_with("https://gitea.example", "token")
|
||||
git_constructor.assert_called_once_with(
|
||||
gitea_url="https://gitea.example",
|
||||
username=settings.bot_username,
|
||||
token="token",
|
||||
askpass_path=settings.askpass_path,
|
||||
commit_name=settings.bot_name,
|
||||
commit_email=settings.bot_email,
|
||||
)
|
||||
opencode_constructor.assert_called_once_with(
|
||||
base_url="https://opencode.example",
|
||||
username=settings.opencode_server_username,
|
||||
password="password",
|
||||
schemas_dir=package_dir / "prompts" / "schemas",
|
||||
health_directory=settings.workspaces_dir,
|
||||
required_models=(
|
||||
(settings.plan_model, settings.plan_variant),
|
||||
(settings.implement_model, settings.implement_variant),
|
||||
(settings.explore_model, settings.explore_variant),
|
||||
(settings.research_model, settings.research_variant),
|
||||
),
|
||||
timeout_seconds=settings.turn_timeout_seconds,
|
||||
)
|
||||
development_constructor.assert_called_once_with(
|
||||
scripts=["python"],
|
||||
scripts_dir=settings.install_scripts_dir,
|
||||
tools_dir=settings.dev_tools_dir,
|
||||
timeout_seconds=settings.install_script_timeout_seconds,
|
||||
python_version=settings.python_version,
|
||||
dotnet_channel=settings.dotnet_channel,
|
||||
)
|
||||
prompt_constructor.assert_called_once_with()
|
||||
services_constructor.assert_called_once_with(
|
||||
settings=settings,
|
||||
repository=repository,
|
||||
gitea=gitea,
|
||||
git=git,
|
||||
opencode=opencode,
|
||||
prompts=prompts,
|
||||
development=development,
|
||||
)
|
||||
worker_constructor.assert_called_once_with(
|
||||
repository=repository,
|
||||
gitea=gitea,
|
||||
opencode=opencode,
|
||||
services=services,
|
||||
poll_seconds=settings.worker_poll_seconds,
|
||||
max_concurrent_jobs=4,
|
||||
workspaces_dir=settings.workspaces_dir,
|
||||
bot_username=settings.bot_username,
|
||||
)
|
||||
assert settings.data_dir.is_dir()
|
||||
assert settings.workspaces_dir.is_dir()
|
||||
assert built.repository is repository
|
||||
assert built.gitea is gitea
|
||||
assert built.git is git
|
||||
assert built.opencode is opencode
|
||||
assert built.worker is worker
|
||||
@@ -0,0 +1,89 @@
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
|
||||
|
||||
def test_entrypoint_loads_secrets_writes_tea_login_and_executes_command(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
password = tmp_path / "opencode-password"
|
||||
password.write_text("server-secret")
|
||||
token = tmp_path / "gitea-token"
|
||||
token.write_text("gitea-secret\n")
|
||||
config_home = tmp_path / "config"
|
||||
environment = {
|
||||
**os.environ,
|
||||
"OPENCODE_SERVER_PASSWORD_FILE": str(password),
|
||||
"AGENTCI_GITEA_TOKEN_FILE": str(token),
|
||||
"AGENTCI_TEA_CONFIG_HOME": str(config_home),
|
||||
"AGENTCI_GITEA_URL": "https://gitea.example",
|
||||
}
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"/bin/sh",
|
||||
str(ROOT / "scripts" / "entrypoint.sh"),
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
'printf "%s\\n" "$OPENCODE_SERVER_PASSWORD"; printf "arg=%s\\n" "$@"',
|
||||
"child",
|
||||
"first",
|
||||
"second value",
|
||||
],
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.splitlines() == [
|
||||
"server-secret",
|
||||
"arg=first",
|
||||
"arg=second value",
|
||||
]
|
||||
config_path = config_home / "tea" / "config.yml"
|
||||
assert json.loads(config_path.read_text()) == {
|
||||
"logins": [
|
||||
{
|
||||
"name": "agentci",
|
||||
"url": "https://gitea.example",
|
||||
"token": "gitea-secret",
|
||||
"default": True,
|
||||
"version_check": False,
|
||||
}
|
||||
],
|
||||
"preferences": {},
|
||||
}
|
||||
assert stat.S_IMODE(config_path.stat().st_mode) == 0o600
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("prompt", "expected"),
|
||||
[
|
||||
("Username for 'https://gitea.example':", "agentci-bot\n"),
|
||||
("Password for 'https://agentci@gitea.example':", "token-secret\n"),
|
||||
],
|
||||
)
|
||||
def test_gitea_askpass_selects_credential_for_prompt(prompt: str, expected: str) -> None:
|
||||
result = subprocess.run(
|
||||
["/bin/sh", str(ROOT / "scripts" / "gitea-askpass.sh"), prompt],
|
||||
env={
|
||||
"AGENTCI_GIT_USERNAME": "agentci-bot",
|
||||
"AGENTCI_GIT_PASSWORD": "token-secret",
|
||||
},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout == expected
|
||||
@@ -1,75 +0,0 @@
|
||||
from dataclasses import FrozenInstanceError
|
||||
|
||||
import pytest
|
||||
|
||||
from agentci.domain.events import (
|
||||
CommandReceived,
|
||||
CommentLinked,
|
||||
JobCompleted,
|
||||
JobStarted,
|
||||
PermissionGranted,
|
||||
ServiceRestarted,
|
||||
)
|
||||
from agentci.domain.models import JobKind, JobStatus
|
||||
from agentci.domain.state_machine import InvalidTransition, next_state, render_job_comment
|
||||
|
||||
|
||||
def received(body: str = "/agent plan message"):
|
||||
return next_state(
|
||||
None,
|
||||
CommandReceived(
|
||||
job_id="job",
|
||||
delivery_id="delivery",
|
||||
receive_sequence=1,
|
||||
command_body=body,
|
||||
target_key="org/repo:issue:1",
|
||||
repo_owner="org",
|
||||
repo_name="repo",
|
||||
issue_number=1,
|
||||
pr_number=None,
|
||||
requester="alice",
|
||||
comment_id=4,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_permission_parses_and_queues_execution() -> None:
|
||||
transition = next_state(received().state, PermissionGranted(job_id="job"))
|
||||
assert transition.state.status is JobStatus.QUEUED
|
||||
assert transition.state.kind is JobKind.PLAN
|
||||
assert transition.state.message == "message"
|
||||
assert [(item.listener, item.queue) for item in transition.notifications] == [
|
||||
("execute", "jobs"),
|
||||
("reconcile_comment", "control"),
|
||||
]
|
||||
|
||||
|
||||
def test_invalid_syntax_is_rejected_after_permission() -> None:
|
||||
transition = next_state(received("/agent nonsense").state, PermissionGranted(job_id="job"))
|
||||
assert transition.state.status is JobStatus.REJECTED
|
||||
assert "Unknown" in (transition.state.error or "")
|
||||
|
||||
|
||||
def test_running_completion_and_restart_are_explicit() -> None:
|
||||
queued = next_state(received().state, PermissionGranted(job_id="job")).state
|
||||
running = next_state(queued, JobStarted(job_id="job")).state
|
||||
completed = next_state(running, JobCompleted(job_id="job", comment_body="# Result")).state
|
||||
assert completed.status is JobStatus.SUCCEEDED
|
||||
assert "# Result" in render_job_comment(completed)
|
||||
assert next_state(completed, ServiceRestarted(job_id="job")).state == completed
|
||||
|
||||
|
||||
def test_comment_link_is_allowed_on_terminal_state() -> None:
|
||||
queued = next_state(received().state, PermissionGranted(job_id="job")).state
|
||||
running = next_state(queued, JobStarted(job_id="job")).state
|
||||
completed = next_state(running, JobCompleted(job_id="job", comment_body="ok")).state
|
||||
linked = next_state(completed, CommentLinked(job_id="job", comment_id=9)).state
|
||||
assert linked.accepted_comment_id == 9
|
||||
|
||||
|
||||
def test_state_is_immutable_and_invalid_transitions_fail() -> None:
|
||||
state = received().state
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
state.stage = "changed" # type: ignore[misc]
|
||||
with pytest.raises(InvalidTransition):
|
||||
next_state(state, JobStarted(job_id="job"))
|
||||
@@ -1,156 +0,0 @@
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from agentci.adapters.storage import Storage
|
||||
from agentci.domain.events import (
|
||||
JobStarted,
|
||||
PermissionDenied,
|
||||
PermissionGranted,
|
||||
WorkflowCreated,
|
||||
)
|
||||
from agentci.domain.models import CommandEvent, Workflow, WorkflowKind, WorkflowStatus
|
||||
from agentci.state_machine import StateMachine
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def storage(tmp_path: Path) -> Storage:
|
||||
migrations = Path(__file__).parents[1] / "src" / "agentci" / "migrations"
|
||||
value = Storage(tmp_path / "state.sqlite3", migrations)
|
||||
await value.initialize()
|
||||
return value
|
||||
|
||||
|
||||
def command(
|
||||
delivery: str,
|
||||
body: str = "/agent plan",
|
||||
*,
|
||||
issue: int = 3,
|
||||
pr: int | None = None,
|
||||
) -> CommandEvent:
|
||||
return CommandEvent(
|
||||
delivery_id=delivery,
|
||||
comment_id=int(delivery.rsplit("-", 1)[-1]),
|
||||
repo_owner="alice",
|
||||
repo_name="repo",
|
||||
issue_number=issue,
|
||||
pr_number=pr,
|
||||
requester="alice",
|
||||
body=body,
|
||||
)
|
||||
|
||||
|
||||
async def test_receive_is_idempotent_without_consuming_sequence(storage: Storage) -> None:
|
||||
host = StateMachine(storage)
|
||||
first = await host.receive(command("delivery-1"))
|
||||
duplicate = await host.receive(command("delivery-1"))
|
||||
second = await host.receive(command("delivery-2"))
|
||||
|
||||
assert not first.duplicate
|
||||
assert duplicate.duplicate
|
||||
assert duplicate.state.id == first.state.id
|
||||
assert second.state.receive_sequence == first.state.receive_sequence + 1
|
||||
|
||||
|
||||
async def test_received_job_blocks_later_execute_task_for_same_target(
|
||||
storage: Storage,
|
||||
) -> None:
|
||||
host = StateMachine(storage)
|
||||
first = (await host.receive(command("delivery-1"))).state
|
||||
second = (await host.receive(command("delivery-2"))).state
|
||||
await host.evolve("grant-2", PermissionGranted(job_id=second.id))
|
||||
|
||||
assert await storage.claim_task("jobs") is None
|
||||
|
||||
await host.evolve("deny-1", PermissionDenied(job_id=first.id))
|
||||
task = await storage.claim_task("jobs")
|
||||
assert task is not None
|
||||
assert task.job_id == second.id
|
||||
|
||||
|
||||
async def test_received_job_does_not_block_a_different_target(storage: Storage) -> None:
|
||||
host = StateMachine(storage)
|
||||
await host.receive(command("delivery-1", issue=3))
|
||||
second = (await host.receive(command("delivery-2", issue=4))).state
|
||||
await host.evolve("grant-2", PermissionGranted(job_id=second.id))
|
||||
|
||||
task = await storage.claim_task("jobs")
|
||||
|
||||
assert task is not None
|
||||
assert task.job_id == second.id
|
||||
|
||||
|
||||
async def test_claims_multiple_eligible_targets_without_duplicates(storage: Storage) -> None:
|
||||
host = StateMachine(storage)
|
||||
first = (await host.receive(command("delivery-1", issue=3))).state
|
||||
second = (await host.receive(command("delivery-2", issue=4))).state
|
||||
await host.evolve("grant-1", PermissionGranted(job_id=first.id))
|
||||
await host.evolve("grant-2", PermissionGranted(job_id=second.id))
|
||||
|
||||
claimed = [await storage.claim_task("jobs"), await storage.claim_task("jobs")]
|
||||
|
||||
assert [task.job_id for task in claimed if task is not None] == [first.id, second.id]
|
||||
assert await storage.claim_task("jobs") is None
|
||||
|
||||
|
||||
async def test_started_and_finished_timestamps_are_owned_by_store(storage: Storage) -> None:
|
||||
host = StateMachine(storage)
|
||||
state = (await host.receive(command("delivery-1"))).state
|
||||
state = (await host.evolve("grant", PermissionGranted(job_id=state.id))).state
|
||||
state = (await host.evolve("start", JobStarted(job_id=state.id))).state
|
||||
with sqlite3.connect(storage.database_path) as connection:
|
||||
row = connection.execute(
|
||||
"SELECT started_at, finished_at FROM jobs WHERE id=?", (state.id,)
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert row[0] is not None
|
||||
assert row[1] is None
|
||||
|
||||
|
||||
async def test_workflow_queries_and_completed_protection(storage: Storage, tmp_path: Path) -> None:
|
||||
workflow = Workflow(
|
||||
id="workflow-1",
|
||||
kind=WorkflowKind.PLAN,
|
||||
repo_owner="alice",
|
||||
repo_name="repo",
|
||||
issue_number=3,
|
||||
workspace_path=tmp_path / "repo",
|
||||
base_sha="abc",
|
||||
artifact="# Plan",
|
||||
status=WorkflowStatus.COMPLETED,
|
||||
)
|
||||
await storage.create_workflow(workflow)
|
||||
await storage.fail_job_workflow("missing-job")
|
||||
loaded = await storage.latest_workflow("alice", "repo", 3, WorkflowKind.PLAN)
|
||||
assert loaded is not None
|
||||
assert loaded.status is WorkflowStatus.COMPLETED
|
||||
|
||||
|
||||
async def test_workflow_creation_and_job_link_are_atomic(storage: Storage, tmp_path: Path) -> None:
|
||||
host = StateMachine(storage)
|
||||
state = (await host.receive(command("delivery-1"))).state
|
||||
state = (await host.evolve("grant", PermissionGranted(job_id=state.id))).state
|
||||
state = (await host.evolve("start", JobStarted(job_id=state.id))).state
|
||||
workflow = Workflow(
|
||||
id="workflow-atomic",
|
||||
kind=WorkflowKind.PLAN,
|
||||
repo_owner="alice",
|
||||
repo_name="repo",
|
||||
issue_number=3,
|
||||
workspace_path=tmp_path / "repo",
|
||||
base_sha="abc",
|
||||
)
|
||||
result = await host.evolve(
|
||||
"workflow-created",
|
||||
WorkflowCreated(job_id=state.id, workflow=workflow, stage="planning"),
|
||||
)
|
||||
assert result.state.workflow_id == workflow.id
|
||||
assert await storage.get_workflow(workflow.id) is not None
|
||||
|
||||
|
||||
async def test_schema_has_receive_sequence_and_no_version(storage: Storage) -> None:
|
||||
with sqlite3.connect(storage.database_path) as connection:
|
||||
columns = {row[1] for row in connection.execute("PRAGMA table_info(jobs)")}
|
||||
assert "receive_sequence" in columns
|
||||
assert "version" not in columns
|
||||
+147
-35
@@ -1,28 +1,31 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient, Response
|
||||
|
||||
from agentci.api.webhook import _event_from_payload, _handle_command, valid_signature
|
||||
from agentci.engine.model import IncomingCommand
|
||||
from agentci.webhook import _event_from_payload, router, valid_signature
|
||||
|
||||
|
||||
class FakeHost:
|
||||
def __init__(self, duplicate: bool = False) -> None:
|
||||
self.events = []
|
||||
class FakeRepository:
|
||||
def __init__(self, *, duplicate: bool = False) -> None:
|
||||
self.accepted: list[IncomingCommand] = []
|
||||
self.duplicate = duplicate
|
||||
|
||||
async def receive(self, event):
|
||||
self.events.append(event)
|
||||
state = SimpleNamespace(id="job", receive_sequence=1)
|
||||
return SimpleNamespace(state=state, duplicate=self.duplicate)
|
||||
async def accept(self, event: IncomingCommand) -> SimpleNamespace:
|
||||
self.accepted.append(event)
|
||||
job = SimpleNamespace(id="job", receive_sequence=1)
|
||||
return SimpleNamespace(job=job, duplicate=self.duplicate)
|
||||
|
||||
|
||||
def payload(body: str, *, is_pull: bool = False) -> dict:
|
||||
def payload(body: str, *, is_pull: bool = False, requester: str = "alice") -> dict:
|
||||
value = {
|
||||
"action": "created",
|
||||
"comment": {"id": 8, "body": body, "user": {"login": "alice"}},
|
||||
"comment": {"id": 8, "body": body, "user": {"login": requester}},
|
||||
"repository": {"name": "repo", "owner": {"login": "org"}},
|
||||
"issue": {"number": 4},
|
||||
"is_pull": is_pull,
|
||||
@@ -32,40 +35,149 @@ def payload(body: str, *, is_pull: bool = False) -> dict:
|
||||
return value
|
||||
|
||||
|
||||
async def test_command_is_forwarded_without_parsing() -> None:
|
||||
host = FakeHost()
|
||||
event = _event_from_payload(
|
||||
"delivery", payload("/agent iterate\n\nkeep raw body", is_pull=True)
|
||||
def encoded(value: object) -> bytes:
|
||||
return json.dumps(value).encode()
|
||||
|
||||
|
||||
def sign(body: bytes, secret: bytes = b"secret") -> str:
|
||||
return hmac.new(secret, body, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
async def post_webhook(
|
||||
repository: FakeRepository,
|
||||
body: bytes,
|
||||
*,
|
||||
event: str = "issue_comment",
|
||||
delivery: str | None = "delivery",
|
||||
signature: str | None = None,
|
||||
bot_username: str = "agentci",
|
||||
) -> Response:
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
app.state.runtime = SimpleNamespace(
|
||||
repository=repository,
|
||||
settings=SimpleNamespace(webhook_secret=b"secret", bot_username=bot_username),
|
||||
)
|
||||
assert event is not None
|
||||
response = await _handle_command(SimpleNamespace(state_machine=host), event)
|
||||
headers = {
|
||||
"X-Gitea-Event-Type": event,
|
||||
"X-Gitea-Signature": sign(body) if signature is None else signature,
|
||||
}
|
||||
if delivery is not None:
|
||||
headers["X-Gitea-Delivery"] = delivery
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
return await client.post("/webhooks/gitea", content=body, headers=headers)
|
||||
|
||||
|
||||
async def test_signed_supported_command_is_accepted_once_with_raw_body() -> None:
|
||||
repository = FakeRepository()
|
||||
body = encoded(payload("/agent iterate\n\nkeep raw body", is_pull=True))
|
||||
|
||||
response = await post_webhook(repository, body)
|
||||
|
||||
assert response.status_code == 202
|
||||
assert host.events[0].body == "/agent iterate\n\nkeep raw body"
|
||||
assert len(repository.accepted) == 1
|
||||
event = repository.accepted[0]
|
||||
assert event.delivery_id == "delivery"
|
||||
assert event.target_key == "org/repo:pr:4"
|
||||
assert event.requester == "alice"
|
||||
assert event.body == "/agent iterate\n\nkeep raw body"
|
||||
|
||||
|
||||
async def test_duplicate_returns_200() -> None:
|
||||
event = _event_from_payload("delivery", payload("/agent plan"))
|
||||
assert event is not None
|
||||
response = await _handle_command(SimpleNamespace(state_machine=FakeHost(True)), event)
|
||||
assert response.status_code == 200
|
||||
@pytest.mark.parametrize("event", ["issue_comment", "push"])
|
||||
async def test_invalid_signature_precedes_parsing_and_event_filtering(event: str) -> None:
|
||||
repository = FakeRepository()
|
||||
|
||||
response = await post_webhook(repository, b"not-json", event=event, signature="invalid")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {"detail": "Invalid webhook signature"}
|
||||
assert repository.accepted == []
|
||||
|
||||
|
||||
async def test_missing_delivery_is_rejected() -> None:
|
||||
event = _event_from_payload("", payload("/agent plan"))
|
||||
assert event is not None
|
||||
with pytest.raises(HTTPException) as raised:
|
||||
await _handle_command(SimpleNamespace(state_machine=FakeHost()), event)
|
||||
assert raised.value.status_code == 400
|
||||
async def test_signed_unsupported_event_is_ignored_without_parsing() -> None:
|
||||
repository = FakeRepository()
|
||||
|
||||
response = await post_webhook(repository, b"not-json", event="push")
|
||||
|
||||
async def test_non_command_is_ignored() -> None:
|
||||
event = _event_from_payload("delivery", payload("ordinary discussion"))
|
||||
assert event is not None
|
||||
response = await _handle_command(SimpleNamespace(state_machine=FakeHost()), event)
|
||||
assert response.status_code == 204
|
||||
assert repository.accepted == []
|
||||
|
||||
|
||||
def test_signature_validation() -> None:
|
||||
signature = hmac.new(b"secret", b"{}", hashlib.sha256).hexdigest()
|
||||
@pytest.mark.parametrize(
|
||||
("command", "requester"),
|
||||
[("ordinary discussion", "alice"), ("/agent plan", "AgentCI")],
|
||||
)
|
||||
async def test_non_command_and_bot_comment_are_ignored(command: str, requester: str) -> None:
|
||||
repository = FakeRepository()
|
||||
|
||||
response = await post_webhook(repository, encoded(payload(command, requester=requester)))
|
||||
|
||||
assert response.status_code == 204
|
||||
assert repository.accepted == []
|
||||
|
||||
|
||||
async def test_duplicate_delivery_returns_ok() -> None:
|
||||
repository = FakeRepository(duplicate=True)
|
||||
|
||||
response = await post_webhook(repository, encoded(payload("/agent plan")))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert len(repository.accepted) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
b"{",
|
||||
encoded([]),
|
||||
encoded({"action": "created"}),
|
||||
encoded({"action": "created", "comment": {}}),
|
||||
],
|
||||
)
|
||||
async def test_malformed_json_and_payload_contract_return_bad_request(body: bytes) -> None:
|
||||
repository = FakeRepository()
|
||||
|
||||
response = await post_webhook(repository, body)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "Invalid webhook payload"}
|
||||
assert repository.accepted == []
|
||||
|
||||
|
||||
async def test_command_requires_delivery_header() -> None:
|
||||
repository = FakeRepository()
|
||||
|
||||
response = await post_webhook(repository, encoded(payload("/agent plan")), delivery=None)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "Missing X-Gitea-Delivery"}
|
||||
assert repository.accepted == []
|
||||
|
||||
|
||||
async def test_non_created_comment_is_ignored_without_nested_payload() -> None:
|
||||
repository = FakeRepository()
|
||||
|
||||
response = await post_webhook(repository, encoded({"action": "edited"}))
|
||||
|
||||
assert response.status_code == 204
|
||||
assert repository.accepted == []
|
||||
|
||||
|
||||
def test_payload_parser_supports_owner_username_and_pull_request() -> None:
|
||||
value = payload("/agent plan", is_pull=True)
|
||||
value["repository"]["owner"] = {"username": "fallback-owner"}
|
||||
|
||||
event = _event_from_payload("delivery", value)
|
||||
|
||||
assert event is not None
|
||||
assert event.repo_owner == "fallback-owner"
|
||||
assert event.issue_number == 4
|
||||
assert event.pr_number == 4
|
||||
|
||||
|
||||
def test_signature_validation_requires_exact_nonempty_digest() -> None:
|
||||
signature = sign(b"{}")
|
||||
|
||||
assert valid_signature(b"secret", b"{}", signature)
|
||||
assert not valid_signature(b"secret", b"{}", "")
|
||||
assert not valid_signature(b"secret", b"{}", "bad")
|
||||
|
||||
+597
-42
@@ -1,44 +1,586 @@
|
||||
import asyncio
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
|
||||
from agentci.domain.models import Workflow, WorkflowKind
|
||||
from agentci.domain.state_machine import JobState
|
||||
import pytest
|
||||
|
||||
from agentci.engine.events import (
|
||||
CommentLinked,
|
||||
JobCompleted,
|
||||
JobFailed,
|
||||
JobStarted,
|
||||
PermissionDenied,
|
||||
PermissionGranted,
|
||||
ServiceRestarted,
|
||||
)
|
||||
from agentci.engine.events import (
|
||||
JobRejected as RejectedEvent,
|
||||
)
|
||||
from agentci.engine.model import (
|
||||
IncomingCommand,
|
||||
Job,
|
||||
JobKind,
|
||||
JobStatus,
|
||||
QueueName,
|
||||
Task,
|
||||
TaskKind,
|
||||
Workflow,
|
||||
WorkflowKind,
|
||||
)
|
||||
from agentci.engine.reducer import render_job_comment
|
||||
from agentci.engine.repository import Repository
|
||||
from agentci.gitea import CommentInfo
|
||||
from agentci.worker import Worker, _safe_error
|
||||
from agentci.workflows.render import JobRejected
|
||||
|
||||
MIGRATIONS = Path(__file__).parents[1] / "src" / "agentci" / "migrations"
|
||||
|
||||
|
||||
class FakeStorage:
|
||||
def __init__(self, workflow=None) -> None:
|
||||
class FakeRepository:
|
||||
def __init__(
|
||||
self,
|
||||
value: Job | None = None,
|
||||
*,
|
||||
workflow: Workflow | None = None,
|
||||
) -> None:
|
||||
self.job = value
|
||||
self.workflow = workflow
|
||||
self.tasks: list[Task] = []
|
||||
self.claimed_queues: list[QueueName] = []
|
||||
self.completed: list[int] = []
|
||||
self.retried: list[tuple[int, int, str]] = []
|
||||
self.events: list[tuple[str, object]] = []
|
||||
self.operations: list[str] = []
|
||||
self.running: list[Job] = []
|
||||
self.failed_workflows: list[str] = []
|
||||
self.complete_stop: asyncio.Event | None = None
|
||||
|
||||
async def get_workflow(self, _workflow_id):
|
||||
async def claim_task(self, queue: QueueName) -> Task | None:
|
||||
self.claimed_queues.append(queue)
|
||||
return self.tasks.pop(0) if self.tasks else None
|
||||
|
||||
async def complete_task(self, task_id: int) -> None:
|
||||
self.completed.append(task_id)
|
||||
if self.complete_stop is not None:
|
||||
self.complete_stop.set()
|
||||
|
||||
async def retry_task(self, task_id: int, attempts: int, error: str) -> None:
|
||||
self.retried.append((task_id, attempts, error))
|
||||
|
||||
async def get_job(self, _job_id: str) -> Job | None:
|
||||
return self.job
|
||||
|
||||
async def apply(self, event_id: str, event: object) -> SimpleNamespace:
|
||||
self.operations.append(f"apply:{event_id}")
|
||||
self.events.append((event_id, event))
|
||||
if isinstance(event, JobStarted) and self.job is not None:
|
||||
self.job = replace(self.job, status=JobStatus.RUNNING, stage="starting")
|
||||
elif isinstance(event, CommentLinked) and self.job is not None:
|
||||
self.job = replace(self.job, accepted_comment_id=event.comment_id)
|
||||
return SimpleNamespace(job=self.job)
|
||||
|
||||
async def recover_tasks(self) -> None:
|
||||
self.operations.append("recover_tasks")
|
||||
|
||||
async def running_jobs(self) -> list[Job]:
|
||||
self.operations.append("running_jobs")
|
||||
return self.running
|
||||
|
||||
async def get_workflow(self, _workflow_id: str) -> Workflow | None:
|
||||
return self.workflow
|
||||
|
||||
async def fail_job_workflow(self, job_id: str) -> None:
|
||||
self.failed_workflows.append(job_id)
|
||||
|
||||
|
||||
class FakeGitea:
|
||||
def __init__(self, *, permitted: bool = True) -> None:
|
||||
self.permitted = permitted
|
||||
self.permission_calls: list[tuple[str, str, str]] = []
|
||||
self.update_results: list[bool] = [True]
|
||||
self.update_calls: list[tuple[str, str, int, str]] = []
|
||||
self.comments: list[CommentInfo] = []
|
||||
self.issue_comment_calls: list[tuple[str, str, int]] = []
|
||||
self.created_comment_id = 42
|
||||
self.create_calls: list[tuple[str, str, int, str]] = []
|
||||
|
||||
async def has_write_permission(self, owner: str, repo: str, user: str) -> bool:
|
||||
self.permission_calls.append((owner, repo, user))
|
||||
return self.permitted
|
||||
|
||||
async def update_comment(self, owner: str, repo: str, comment_id: int, body: str) -> bool:
|
||||
self.update_calls.append((owner, repo, comment_id, body))
|
||||
if len(self.update_results) > 1:
|
||||
return self.update_results.pop(0)
|
||||
return self.update_results[0]
|
||||
|
||||
async def issue_comments(self, owner: str, repo: str, issue: int) -> list[CommentInfo]:
|
||||
self.issue_comment_calls.append((owner, repo, issue))
|
||||
return self.comments
|
||||
|
||||
async def create_comment(self, owner: str, repo: str, issue: int, body: str) -> int:
|
||||
self.create_calls.append((owner, repo, issue, body))
|
||||
return self.created_comment_id
|
||||
|
||||
|
||||
class FakeOpenCode:
|
||||
def __init__(self) -> None:
|
||||
self.aborted = set()
|
||||
def __init__(self, ready: list[bool] | None = None) -> None:
|
||||
self.ready_results = ready or [True]
|
||||
self.ready_calls = 0
|
||||
self.aborted: list[tuple[str, Path]] = []
|
||||
|
||||
async def abort(self, session_id, workspace):
|
||||
self.aborted.add((session_id, workspace))
|
||||
async def ready(self) -> bool:
|
||||
self.ready_calls += 1
|
||||
if len(self.ready_results) > 1:
|
||||
return self.ready_results.pop(0)
|
||||
return self.ready_results[0]
|
||||
|
||||
async def abort(self, session_id: str, workspace: Path) -> None:
|
||||
self.aborted.append((session_id, workspace))
|
||||
|
||||
|
||||
def worker(tmp_path: Path, storage: FakeStorage, opencode: FakeOpenCode) -> Worker:
|
||||
def job(
|
||||
*,
|
||||
job_id: str = "job",
|
||||
status: JobStatus = JobStatus.RECEIVED,
|
||||
workflow_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
accepted_comment_id: int | None = None,
|
||||
) -> Job:
|
||||
return Job(
|
||||
id=job_id,
|
||||
target_key="org/repo:issue:1",
|
||||
repo_owner="org",
|
||||
repo_name="repo",
|
||||
issue_number=1,
|
||||
pr_number=None,
|
||||
requester="alice",
|
||||
comment_id=1,
|
||||
delivery_id=f"delivery-{job_id}",
|
||||
receive_sequence=1,
|
||||
command_body="/agent plan",
|
||||
kind=JobKind.PLAN if status is not JobStatus.RECEIVED else None,
|
||||
status=status,
|
||||
stage=status.value,
|
||||
workflow_id=workflow_id,
|
||||
runtime_session_id=session_id,
|
||||
accepted_comment_id=accepted_comment_id,
|
||||
)
|
||||
|
||||
|
||||
def task(
|
||||
kind: TaskKind = TaskKind.EXECUTE,
|
||||
queue: QueueName = QueueName.JOBS,
|
||||
*,
|
||||
task_id: int = 7,
|
||||
) -> Task:
|
||||
return Task(task_id, "job", "source", kind, queue, 2)
|
||||
|
||||
|
||||
def make_worker(
|
||||
tmp_path: Path,
|
||||
repository: FakeRepository | Repository,
|
||||
*,
|
||||
gitea: FakeGitea | None = None,
|
||||
opencode: FakeOpenCode | None = None,
|
||||
max_concurrent_jobs: int = 2,
|
||||
) -> Worker:
|
||||
return Worker(
|
||||
storage=storage, # type: ignore[arg-type]
|
||||
state_machine=SimpleNamespace(), # type: ignore[arg-type]
|
||||
gitea=SimpleNamespace(), # type: ignore[arg-type]
|
||||
opencode=opencode, # type: ignore[arg-type]
|
||||
dispatcher=SimpleNamespace(), # type: ignore[arg-type]
|
||||
repository=repository, # type: ignore[arg-type]
|
||||
gitea=gitea or FakeGitea(), # type: ignore[arg-type]
|
||||
opencode=opencode or FakeOpenCode(), # type: ignore[arg-type]
|
||||
services=SimpleNamespace(), # type: ignore[arg-type]
|
||||
poll_seconds=1,
|
||||
max_concurrent_jobs=2,
|
||||
max_concurrent_jobs=max_concurrent_jobs,
|
||||
workspaces_dir=tmp_path,
|
||||
bot_username="agentci",
|
||||
)
|
||||
|
||||
|
||||
async def test_abort_collects_all_workflow_sessions(tmp_path: Path) -> None:
|
||||
async def test_loop_completes_successful_task(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
repository = FakeRepository()
|
||||
queued_task = task(queue=QueueName.CONTROL)
|
||||
repository.tasks = [queued_task]
|
||||
stop = asyncio.Event()
|
||||
handled: list[Task] = []
|
||||
value = make_worker(tmp_path, repository)
|
||||
|
||||
async def handle(current: Task) -> None:
|
||||
handled.append(current)
|
||||
stop.set()
|
||||
|
||||
monkeypatch.setattr(value, "_handle", handle)
|
||||
|
||||
await value._loop(QueueName.CONTROL, stop)
|
||||
|
||||
assert handled == [queued_task]
|
||||
assert repository.completed == [queued_task.id]
|
||||
assert repository.retried == []
|
||||
|
||||
|
||||
async def test_loop_retries_failed_task(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
repository = FakeRepository()
|
||||
queued_task = task(queue=QueueName.CONTROL)
|
||||
repository.tasks = [queued_task]
|
||||
stop = asyncio.Event()
|
||||
value = make_worker(tmp_path, repository)
|
||||
|
||||
async def fail(_current: Task) -> None:
|
||||
stop.set()
|
||||
raise RuntimeError("provider\nfailed")
|
||||
|
||||
monkeypatch.setattr(value, "_handle", fail)
|
||||
|
||||
await value._loop(QueueName.CONTROL, stop)
|
||||
|
||||
assert repository.completed == []
|
||||
assert repository.retried == [
|
||||
(queued_task.id, queued_task.attempts, "RuntimeError: provider failed")
|
||||
]
|
||||
|
||||
|
||||
async def test_loop_propagates_cancellation_without_rescheduling(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
repository = FakeRepository()
|
||||
queued_task = task(queue=QueueName.CONTROL)
|
||||
repository.tasks = [queued_task]
|
||||
stop = asyncio.Event()
|
||||
value = make_worker(tmp_path, repository)
|
||||
|
||||
async def cancel(_current: Task) -> None:
|
||||
stop.set()
|
||||
raise asyncio.CancelledError
|
||||
|
||||
monkeypatch.setattr(value, "_handle", cancel)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await value._loop(QueueName.CONTROL, stop)
|
||||
|
||||
assert repository.completed == []
|
||||
assert repository.retried == []
|
||||
|
||||
|
||||
async def test_loop_completes_task_whose_job_no_longer_exists(tmp_path: Path) -> None:
|
||||
repository = FakeRepository(None)
|
||||
missing_job_task = task(queue=QueueName.CONTROL)
|
||||
repository.tasks = [missing_job_task]
|
||||
stop = asyncio.Event()
|
||||
repository.complete_stop = stop
|
||||
|
||||
await make_worker(tmp_path, repository)._loop(QueueName.CONTROL, stop)
|
||||
|
||||
assert repository.completed == [missing_job_task.id]
|
||||
assert repository.retried == []
|
||||
|
||||
|
||||
async def test_jobs_queue_waits_for_provider_readiness(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
repository = FakeRepository()
|
||||
queued_task = task()
|
||||
repository.tasks = [queued_task]
|
||||
opencode = FakeOpenCode([False, True])
|
||||
stop = asyncio.Event()
|
||||
waits: list[asyncio.Event] = []
|
||||
value = make_worker(tmp_path, repository, opencode=opencode)
|
||||
|
||||
async def wait(current_stop: asyncio.Event) -> None:
|
||||
waits.append(current_stop)
|
||||
|
||||
async def handle(_current: Task) -> None:
|
||||
stop.set()
|
||||
|
||||
monkeypatch.setattr(value, "_wait", wait)
|
||||
monkeypatch.setattr(value, "_handle", handle)
|
||||
|
||||
await value._loop(QueueName.JOBS, stop)
|
||||
|
||||
assert opencode.ready_calls == 2
|
||||
assert waits == [stop]
|
||||
assert repository.claimed_queues == [QueueName.JOBS]
|
||||
assert repository.completed == [queued_task.id]
|
||||
|
||||
|
||||
async def test_run_recovers_before_starting_configured_consumers(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
value = make_worker(tmp_path, FakeRepository(), max_concurrent_jobs=3)
|
||||
calls: list[str | QueueName] = []
|
||||
|
||||
async def recover() -> None:
|
||||
calls.append("recover")
|
||||
|
||||
async def loop(queue: QueueName, _stop: asyncio.Event) -> None:
|
||||
calls.append(queue)
|
||||
|
||||
monkeypatch.setattr(value, "_recover", recover)
|
||||
monkeypatch.setattr(value, "_loop", loop)
|
||||
|
||||
await value.run(asyncio.Event())
|
||||
|
||||
assert calls[0] == "recover"
|
||||
assert calls.count(QueueName.CONTROL) == 1
|
||||
assert calls.count(QueueName.JOBS) == 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("permitted", "event_type", "suffix"),
|
||||
[
|
||||
(True, PermissionGranted, "permission-granted"),
|
||||
(False, PermissionDenied, "permission-denied"),
|
||||
],
|
||||
)
|
||||
async def test_authorize_records_permission_outcome(
|
||||
tmp_path: Path,
|
||||
permitted: bool,
|
||||
event_type: type[PermissionGranted] | type[PermissionDenied],
|
||||
suffix: str,
|
||||
) -> None:
|
||||
current = job()
|
||||
repository = FakeRepository(current)
|
||||
gitea = FakeGitea(permitted=permitted)
|
||||
authorize = task(TaskKind.AUTHORIZE, QueueName.CONTROL)
|
||||
|
||||
await make_worker(tmp_path, repository, gitea=gitea)._authorize(authorize, current)
|
||||
|
||||
assert gitea.permission_calls == [("org", "repo", "alice")]
|
||||
assert repository.events[0][0] == f"task:{authorize.id}:{suffix}"
|
||||
assert isinstance(repository.events[0][1], event_type)
|
||||
|
||||
|
||||
async def test_authorize_ignores_job_after_received_status(tmp_path: Path) -> None:
|
||||
current = job(status=JobStatus.QUEUED)
|
||||
repository = FakeRepository(current)
|
||||
gitea = FakeGitea()
|
||||
|
||||
await make_worker(tmp_path, repository, gitea=gitea)._authorize(
|
||||
task(TaskKind.AUTHORIZE, QueueName.CONTROL), current
|
||||
)
|
||||
|
||||
assert gitea.permission_calls == []
|
||||
assert repository.events == []
|
||||
|
||||
|
||||
async def test_authorize_integrates_with_real_repository(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = Repository(tmp_path / "state.sqlite3", MIGRATIONS)
|
||||
await repository.initialize()
|
||||
accepted = await repository.accept(
|
||||
IncomingCommand(
|
||||
delivery_id="delivery-real",
|
||||
comment_id=1,
|
||||
repo_owner="org",
|
||||
repo_name="repo",
|
||||
issue_number=1,
|
||||
pr_number=None,
|
||||
requester="alice",
|
||||
body="/agent plan write tests",
|
||||
)
|
||||
)
|
||||
authorize = await repository.claim_task(QueueName.CONTROL)
|
||||
assert authorize is not None
|
||||
assert authorize.kind is TaskKind.AUTHORIZE
|
||||
|
||||
await make_worker(tmp_path, repository)._handle(authorize)
|
||||
|
||||
persisted = await repository.get_job(accepted.job.id)
|
||||
assert persisted is not None
|
||||
assert persisted.status is JobStatus.QUEUED
|
||||
assert persisted.kind is JobKind.PLAN
|
||||
assert persisted.message == "write tests"
|
||||
execute = await repository.claim_task(QueueName.JOBS)
|
||||
assert execute is not None
|
||||
assert execute.kind is TaskKind.EXECUTE
|
||||
|
||||
|
||||
async def test_execute_completes_with_workflow_comment(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
current = job(status=JobStatus.QUEUED)
|
||||
repository = FakeRepository(current)
|
||||
value = make_worker(tmp_path, repository)
|
||||
|
||||
async def dispatch(_job: Job, _run: object, _services: object) -> str:
|
||||
return "final workflow body"
|
||||
|
||||
monkeypatch.setattr("agentci.worker.dispatch", dispatch)
|
||||
execute = task()
|
||||
|
||||
await value._execute(execute, current)
|
||||
|
||||
assert [event_id for event_id, _ in repository.events] == [
|
||||
f"task:{execute.id}:started",
|
||||
f"task:{execute.id}:completed",
|
||||
]
|
||||
assert isinstance(repository.events[0][1], JobStarted)
|
||||
completed = repository.events[1][1]
|
||||
assert isinstance(completed, JobCompleted)
|
||||
assert completed.comment_body == "final workflow body"
|
||||
|
||||
|
||||
async def test_execute_records_expected_rejection(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
current = job(status=JobStatus.QUEUED)
|
||||
repository = FakeRepository(current)
|
||||
|
||||
async def dispatch(_job: Job, _run: object, _services: object) -> str:
|
||||
raise JobRejected("pull request is closed")
|
||||
|
||||
monkeypatch.setattr("agentci.worker.dispatch", dispatch)
|
||||
|
||||
await make_worker(tmp_path, repository)._execute(task(), current)
|
||||
|
||||
rejected = repository.events[-1][1]
|
||||
assert isinstance(rejected, RejectedEvent)
|
||||
assert rejected.reason == "pull request is closed"
|
||||
|
||||
|
||||
async def test_execute_records_failure_at_latest_persisted_stage(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
current = job(status=JobStatus.QUEUED)
|
||||
repository = FakeRepository(current)
|
||||
|
||||
async def dispatch(_job: Job, _run: object, _services: object) -> str:
|
||||
assert repository.job is not None
|
||||
repository.job = replace(repository.job, stage="cloning")
|
||||
raise RuntimeError("provider\nfailed")
|
||||
|
||||
monkeypatch.setattr("agentci.worker.dispatch", dispatch)
|
||||
|
||||
await make_worker(tmp_path, repository)._execute(task(), current)
|
||||
|
||||
failed = repository.events[-1][1]
|
||||
assert isinstance(failed, JobFailed)
|
||||
assert failed.stage == "cloning"
|
||||
assert failed.error == "RuntimeError: provider failed"
|
||||
|
||||
|
||||
async def test_execute_marks_running_job_interrupted_after_restart(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
current = job(status=JobStatus.RUNNING)
|
||||
repository = FakeRepository(current)
|
||||
|
||||
async def unexpected_dispatch(_job: Job, _run: object, _services: object) -> str:
|
||||
pytest.fail("running jobs must not be dispatched again")
|
||||
|
||||
monkeypatch.setattr("agentci.worker.dispatch", unexpected_dispatch)
|
||||
execute = task()
|
||||
|
||||
await make_worker(tmp_path, repository)._execute(execute, current)
|
||||
|
||||
assert repository.events[0][0] == f"task:{execute.id}:interrupted"
|
||||
assert isinstance(repository.events[0][1], ServiceRestarted)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [JobStatus.RECEIVED, JobStatus.SUCCEEDED])
|
||||
async def test_execute_ignores_ineligible_status(tmp_path: Path, status: JobStatus) -> None:
|
||||
current = job(status=status)
|
||||
repository = FakeRepository(current)
|
||||
|
||||
await make_worker(tmp_path, repository)._execute(task(), current)
|
||||
|
||||
assert repository.events == []
|
||||
|
||||
|
||||
async def test_reconcile_updates_linked_comment_in_place(tmp_path: Path) -> None:
|
||||
current = job(status=JobStatus.QUEUED, accepted_comment_id=19)
|
||||
repository = FakeRepository(current)
|
||||
gitea = FakeGitea()
|
||||
|
||||
await make_worker(tmp_path, repository, gitea=gitea)._reconcile(
|
||||
task(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL), current
|
||||
)
|
||||
|
||||
assert gitea.update_calls == [("org", "repo", 19, render_job_comment(current))]
|
||||
assert gitea.issue_comment_calls == []
|
||||
assert gitea.create_calls == []
|
||||
assert repository.events == []
|
||||
|
||||
|
||||
async def test_reconcile_finds_oldest_matching_bot_comment_and_links_it(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
current = job(status=JobStatus.QUEUED, accepted_comment_id=99)
|
||||
marker = f"<!-- agentci:job id={current.id} -->"
|
||||
repository = FakeRepository(current)
|
||||
gitea = FakeGitea()
|
||||
gitea.update_results = [False, True]
|
||||
gitea.comments = [
|
||||
CommentInfo(8, "agentci", f"{marker}\nnewer", ""),
|
||||
CommentInfo(3, "AgentCI", f"{marker}\nolder", ""),
|
||||
CommentInfo(1, "alice", f"{marker}\nnot the bot", ""),
|
||||
CommentInfo(2, "agentci", f"prefix {marker}", ""),
|
||||
]
|
||||
reconcile = task(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL)
|
||||
|
||||
await make_worker(tmp_path, repository, gitea=gitea)._reconcile(reconcile, current)
|
||||
|
||||
assert gitea.issue_comment_calls == [("org", "repo", 1)]
|
||||
assert gitea.create_calls == []
|
||||
assert [call[2] for call in gitea.update_calls] == [99, 3]
|
||||
assert repository.events[0][0] == f"task:{reconcile.id}:comment:3"
|
||||
linked = repository.events[0][1]
|
||||
assert isinstance(linked, CommentLinked)
|
||||
assert linked.comment_id == 3
|
||||
|
||||
|
||||
async def test_reconcile_creates_links_and_populates_missing_comment(tmp_path: Path) -> None:
|
||||
current = job(status=JobStatus.SUCCEEDED)
|
||||
repository = FakeRepository(current)
|
||||
gitea = FakeGitea()
|
||||
gitea.comments = [CommentInfo(1, "alice", "unrelated", "")]
|
||||
reconcile = task(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL)
|
||||
|
||||
await make_worker(tmp_path, repository, gitea=gitea)._reconcile(reconcile, current)
|
||||
|
||||
body = render_job_comment(current)
|
||||
assert gitea.create_calls == [("org", "repo", 1, body)]
|
||||
assert gitea.update_calls == [("org", "repo", 42, body)]
|
||||
assert repository.events[0][0] == f"task:{reconcile.id}:comment:42"
|
||||
linked = repository.events[0][1]
|
||||
assert isinstance(linked, CommentLinked)
|
||||
assert linked.comment_id == 42
|
||||
|
||||
|
||||
async def test_recovery_resets_tasks_before_interrupting_running_jobs(tmp_path: Path) -> None:
|
||||
repository = FakeRepository()
|
||||
repository.running = [
|
||||
job(job_id="first", status=JobStatus.RUNNING),
|
||||
job(job_id="second", status=JobStatus.RUNNING),
|
||||
]
|
||||
|
||||
await make_worker(tmp_path, repository)._recover()
|
||||
|
||||
assert repository.operations == [
|
||||
"recover_tasks",
|
||||
"running_jobs",
|
||||
"apply:recovery:first:service-restarted",
|
||||
"apply:recovery:second:service-restarted",
|
||||
]
|
||||
assert all(isinstance(event, ServiceRestarted) for _, event in repository.events)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("primary", "reviewer", "expected"),
|
||||
[
|
||||
("primary", "reviewer", {"primary", "reviewer"}),
|
||||
("same-session", "same-session", {"same-session"}),
|
||||
],
|
||||
)
|
||||
async def test_abort_collects_and_deduplicates_workflow_sessions(
|
||||
tmp_path: Path,
|
||||
primary: str,
|
||||
reviewer: str,
|
||||
expected: set[str],
|
||||
) -> None:
|
||||
workspace = tmp_path / "workflow" / "repo"
|
||||
workflow = Workflow(
|
||||
id="flow",
|
||||
@@ -48,46 +590,59 @@ async def test_abort_collects_all_workflow_sessions(tmp_path: Path) -> None:
|
||||
issue_number=1,
|
||||
workspace_path=workspace,
|
||||
base_sha="base",
|
||||
primary_session_id="primary",
|
||||
reviewer_session_id="reviewer",
|
||||
primary_session_id=primary,
|
||||
reviewer_session_id=reviewer,
|
||||
)
|
||||
opencode = FakeOpenCode()
|
||||
state = SimpleNamespace(workflow_id="flow", runtime_session_id=None, id="job")
|
||||
await worker(tmp_path, FakeStorage(workflow), opencode)._abort_job_sessions(
|
||||
cast(JobState, state)
|
||||
)
|
||||
assert opencode.aborted == {("primary", workspace), ("reviewer", workspace)}
|
||||
|
||||
await make_worker(
|
||||
tmp_path, FakeRepository(workflow=workflow), opencode=opencode
|
||||
)._abort_job_sessions(job(workflow_id="flow"))
|
||||
|
||||
assert set(opencode.aborted) == {(session, workspace) for session in expected}
|
||||
|
||||
|
||||
async def test_abort_uses_one_shot_fix_workspace(tmp_path: Path) -> None:
|
||||
async def test_abort_uses_one_shot_workspace_without_workflow(tmp_path: Path) -> None:
|
||||
opencode = FakeOpenCode()
|
||||
state = SimpleNamespace(workflow_id=None, runtime_session_id="session", id="job")
|
||||
await worker(tmp_path, FakeStorage(), opencode)._abort_job_sessions(
|
||||
cast(JobState, state)
|
||||
|
||||
await make_worker(tmp_path, FakeRepository(), opencode=opencode)._abort_job_sessions(
|
||||
job(workflow_id="missing", session_id="session")
|
||||
)
|
||||
assert opencode.aborted == {("session", tmp_path / "fix-job" / "repo")}
|
||||
|
||||
assert opencode.aborted == [("session", tmp_path / "fix-job" / "repo")]
|
||||
|
||||
|
||||
async def test_run_starts_configured_job_consumers(tmp_path: Path, monkeypatch) -> None:
|
||||
value = worker(tmp_path, FakeStorage(), FakeOpenCode())
|
||||
queues = []
|
||||
async def test_abort_does_not_mix_one_shot_session_into_existing_workflow(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
workflow = Workflow(
|
||||
id="flow",
|
||||
kind=WorkflowKind.IMPLEMENT,
|
||||
repo_owner="org",
|
||||
repo_name="repo",
|
||||
issue_number=1,
|
||||
workspace_path=tmp_path / "workflow" / "repo",
|
||||
base_sha="base",
|
||||
)
|
||||
opencode = FakeOpenCode()
|
||||
|
||||
async def recover() -> None:
|
||||
pass
|
||||
await make_worker(
|
||||
tmp_path, FakeRepository(workflow=workflow), opencode=opencode
|
||||
)._abort_job_sessions(job(workflow_id="flow", session_id="one-shot"))
|
||||
|
||||
async def loop(queue, _stop) -> None:
|
||||
queues.append(queue)
|
||||
assert opencode.aborted == []
|
||||
|
||||
monkeypatch.setattr(value, "_recover", recover)
|
||||
monkeypatch.setattr(value, "_loop", loop)
|
||||
|
||||
await value.run(asyncio.Event())
|
||||
async def test_abort_without_persisted_sessions_is_noop(tmp_path: Path) -> None:
|
||||
opencode = FakeOpenCode()
|
||||
|
||||
assert queues.count("control") == 1
|
||||
assert queues.count("jobs") == 2
|
||||
await make_worker(tmp_path, FakeRepository(), opencode=opencode)._abort_job_sessions(job())
|
||||
|
||||
assert opencode.aborted == []
|
||||
|
||||
|
||||
def test_safe_error_is_single_line_and_bounded() -> None:
|
||||
value = _safe_error(RuntimeError("bad\n" + "x" * 2000))
|
||||
|
||||
assert "\n" not in value
|
||||
assert len(value) == 1000
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
from dataclasses import replace
|
||||
from enum import StrEnum
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
from agentci.engine.model import Job, JobKind
|
||||
from agentci.engine.run import JobRun
|
||||
from agentci.workflows import dispatch as dispatch_module
|
||||
from agentci.workflows.services import WorkflowServices
|
||||
|
||||
ROUTES = [
|
||||
(JobKind.PLAN, "create_plan"),
|
||||
(JobKind.DISCUSS, "discuss_plan"),
|
||||
(JobKind.ITERATE_PLAN, "iterate_plan"),
|
||||
(JobKind.IMPLEMENT, "implement"),
|
||||
(JobKind.ITERATE_IMPLEMENT, "iterate_implementation"),
|
||||
(JobKind.FIX, "fix_pull_request"),
|
||||
]
|
||||
|
||||
|
||||
def make_job(kind: JobKind | None) -> Job:
|
||||
return Job(
|
||||
id="job-1",
|
||||
kind=kind,
|
||||
target_key="org/repo:issue:7",
|
||||
repo_owner="org",
|
||||
repo_name="repo",
|
||||
issue_number=7,
|
||||
pr_number=None,
|
||||
requester="alice",
|
||||
comment_id=11,
|
||||
delivery_id="delivery-1",
|
||||
receive_sequence=1,
|
||||
command_body="/agent plan",
|
||||
message="request",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("kind", "expected_route"), ROUTES)
|
||||
async def test_dispatch_routes_every_job_kind_and_returns_body(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
kind: JobKind,
|
||||
expected_route: str,
|
||||
) -> None:
|
||||
calls: list[tuple[str, Job]] = []
|
||||
|
||||
def route(name: str):
|
||||
async def invoke(job: Job, _run: JobRun, _services: WorkflowServices) -> str:
|
||||
calls.append((name, job))
|
||||
return f"body from {name}"
|
||||
|
||||
return invoke
|
||||
|
||||
for _, name in ROUTES:
|
||||
monkeypatch.setattr(dispatch_module, name, route(name))
|
||||
|
||||
job = make_job(kind)
|
||||
body = await dispatch_module.dispatch(
|
||||
job,
|
||||
cast(JobRun, object()),
|
||||
cast(WorkflowServices, object()),
|
||||
)
|
||||
|
||||
assert body == f"body from {expected_route}"
|
||||
assert calls == [(expected_route, job)]
|
||||
|
||||
|
||||
async def test_dispatch_rejects_unparsed_command() -> None:
|
||||
with pytest.raises(RuntimeError, match="Cannot dispatch an unparsed command"):
|
||||
await dispatch_module.dispatch(
|
||||
make_job(None),
|
||||
cast(JobRun, object()),
|
||||
cast(WorkflowServices, object()),
|
||||
)
|
||||
|
||||
|
||||
class UnsupportedKind(StrEnum):
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
async def test_dispatch_rejects_unsupported_kind() -> None:
|
||||
unsupported = UnsupportedKind.UNKNOWN
|
||||
job = replace(make_job(JobKind.PLAN), kind=cast(JobKind, unsupported))
|
||||
|
||||
with pytest.raises(KeyError) as error:
|
||||
await dispatch_module.dispatch(
|
||||
job,
|
||||
cast(JobRun, object()),
|
||||
cast(WorkflowServices, object()),
|
||||
)
|
||||
|
||||
assert error.value.args == (unsupported,)
|
||||
@@ -0,0 +1,392 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from agentci.engine.model import Job, JobKind, Workflow, WorkflowKind, WorkflowStatus
|
||||
from agentci.engine.run import JobRun
|
||||
from agentci.gitea import CommentInfo, IssueInfo, PullRequestInfo
|
||||
from agentci.workflows.implementation import implement
|
||||
from agentci.workflows.model import AgentResult, ReviewReport
|
||||
from agentci.workflows.render import JobRejected
|
||||
from agentci.workflows.services import WorkflowServices
|
||||
|
||||
|
||||
class RecordingRun(JobRun):
|
||||
def __init__(self) -> None:
|
||||
self.stages: list[str] = []
|
||||
self.created_workflows: list[tuple[Workflow, str]] = []
|
||||
self.linked_sessions: list[str] = []
|
||||
|
||||
async def stage(self, stage: str) -> None:
|
||||
self.stages.append(stage)
|
||||
|
||||
async def create_workflow(self, workflow: Workflow, stage: str) -> None:
|
||||
self.created_workflows.append((workflow, stage))
|
||||
|
||||
async def link_session(self, session_id: str) -> None:
|
||||
self.linked_sessions.append(session_id)
|
||||
|
||||
|
||||
class FakeRepository:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
implementations: list[Workflow] | None = None,
|
||||
plan: Workflow | None = None,
|
||||
) -> None:
|
||||
self.implementations = implementations or []
|
||||
self.plan = plan
|
||||
self.saved_workflows: list[Workflow] = []
|
||||
|
||||
async def implementation_workflows(self, *_args: object) -> list[Workflow]:
|
||||
return self.implementations
|
||||
|
||||
async def latest_workflow(self, *_args: object) -> Workflow | None:
|
||||
return self.plan
|
||||
|
||||
async def operational_comment_ids(self, *_args: object) -> set[int]:
|
||||
return {99}
|
||||
|
||||
async def save_workflow(self, workflow: Workflow) -> None:
|
||||
self.saved_workflows.append(workflow)
|
||||
|
||||
|
||||
class FakeGitea:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
existing_pulls: dict[int, PullRequestInfo] | None = None,
|
||||
) -> None:
|
||||
self.existing_pulls = existing_pulls or {}
|
||||
self.created_pulls: list[tuple[str, str, dict[str, str]]] = []
|
||||
self.default_branch_calls: list[tuple[str, str]] = []
|
||||
|
||||
async def default_branch(self, owner: str, repo: str) -> str:
|
||||
self.default_branch_calls.append((owner, repo))
|
||||
return "main"
|
||||
|
||||
async def issue(self, *_args: object) -> IssueInfo:
|
||||
return IssueInfo(7, "Fix widget", "The widget is broken.", "open")
|
||||
|
||||
async def issue_comments(self, *_args: object) -> list[CommentInfo]:
|
||||
return [
|
||||
CommentInfo(12, "alice", "Please cover empty input.", "2026-07-01"),
|
||||
CommentInfo(99, "agentci", "Job queued", "2026-07-02"),
|
||||
]
|
||||
|
||||
async def pull_request(self, _owner: str, _repo: str, number: int) -> PullRequestInfo:
|
||||
return self.existing_pulls[number]
|
||||
|
||||
async def create_pull_request(self, owner: str, repo: str, **values: str) -> PullRequestInfo:
|
||||
self.created_pulls.append((owner, repo, values))
|
||||
return pull(number=42, branch=values["head"])
|
||||
|
||||
|
||||
class RecordingGit:
|
||||
def __init__(self, *, changed: bool = True) -> None:
|
||||
self.changed = changed
|
||||
self.calls: list[tuple[Any, ...]] = []
|
||||
|
||||
async def clone(self, owner: str, repo: str, branch: str, destination: Path) -> str:
|
||||
self.calls.append(("clone", owner, repo, branch, destination))
|
||||
return "base-sha"
|
||||
|
||||
async def create_branch(self, workspace: Path, branch: str) -> None:
|
||||
self.calls.append(("create_branch", workspace, branch))
|
||||
|
||||
async def has_changes(self, workspace: Path) -> bool:
|
||||
self.calls.append(("has_changes", workspace))
|
||||
return self.changed
|
||||
|
||||
async def diff_check(self, workspace: Path) -> None:
|
||||
self.calls.append(("diff_check", workspace))
|
||||
|
||||
async def commit(self, workspace: Path, message: str) -> str:
|
||||
self.calls.append(("commit", workspace, message))
|
||||
return "commit-sha"
|
||||
|
||||
async def push(self, workspace: Path, branch: str, *, set_upstream: bool = False) -> None:
|
||||
self.calls.append(("push", workspace, branch, set_upstream))
|
||||
|
||||
|
||||
class RecordingDevelopment:
|
||||
description = "Python 3.13"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.workspaces: list[Path] = []
|
||||
|
||||
async def prepare(self, workspace: Path) -> None:
|
||||
self.workspaces.append(workspace)
|
||||
|
||||
|
||||
class RecordingPrompts:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, dict[str, str]]] = []
|
||||
|
||||
def render(self, name: str, **values: str) -> str:
|
||||
self.calls.append((name, values))
|
||||
return f"rendered {name}"
|
||||
|
||||
|
||||
class RecordingOpenCode:
|
||||
def __init__(self, result: AgentResult, report: ReviewReport) -> None:
|
||||
self.result = result
|
||||
self.report = report
|
||||
self.created_sessions: list[tuple[Path, str]] = []
|
||||
self.resume_calls: list[dict[str, Any]] = []
|
||||
|
||||
async def create_session(self, workspace: Path, title: str) -> str:
|
||||
self.created_sessions.append((workspace, title))
|
||||
return f"{title}-session"
|
||||
|
||||
async def resume(self, **values: Any) -> AgentResult | ReviewReport:
|
||||
self.resume_calls.append(values)
|
||||
if values["result_type"] is AgentResult:
|
||||
return self.result
|
||||
assert values["result_type"] is ReviewReport
|
||||
return self.report
|
||||
|
||||
|
||||
def job() -> Job:
|
||||
return Job(
|
||||
id="job-1",
|
||||
kind=JobKind.IMPLEMENT,
|
||||
target_key="org/repo:issue:7",
|
||||
repo_owner="org",
|
||||
repo_name="repo",
|
||||
issue_number=7,
|
||||
pr_number=None,
|
||||
requester="alice",
|
||||
comment_id=10,
|
||||
delivery_id="delivery-1",
|
||||
receive_sequence=1,
|
||||
command_body="/agent implement",
|
||||
message="Keep the change focused.",
|
||||
)
|
||||
|
||||
|
||||
def workflow(*, pr_number: int | None = None) -> Workflow:
|
||||
return Workflow(
|
||||
id=f"old-{pr_number}",
|
||||
kind=WorkflowKind.IMPLEMENT,
|
||||
repo_owner="org",
|
||||
repo_name="repo",
|
||||
issue_number=7,
|
||||
workspace_path=Path("/old/repo"),
|
||||
base_sha="old-sha",
|
||||
pr_number=pr_number,
|
||||
status=WorkflowStatus.COMPLETED,
|
||||
)
|
||||
|
||||
|
||||
def pull(
|
||||
*,
|
||||
number: int = 8,
|
||||
state: str = "open",
|
||||
merged: bool = False,
|
||||
branch: str = "agent/old",
|
||||
) -> PullRequestInfo:
|
||||
return PullRequestInfo(
|
||||
number=number,
|
||||
title="Existing PR",
|
||||
body="Body",
|
||||
state=state,
|
||||
merged=merged,
|
||||
base_branch="main",
|
||||
head_branch=branch,
|
||||
head_sha="head-sha",
|
||||
head_owner="org",
|
||||
head_repo="repo",
|
||||
)
|
||||
|
||||
|
||||
def make_services(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
changed: bool = True,
|
||||
implementations: list[Workflow] | None = None,
|
||||
existing_pulls: dict[int, PullRequestInfo] | None = None,
|
||||
plan: Workflow | None = None,
|
||||
) -> tuple[
|
||||
WorkflowServices,
|
||||
FakeRepository,
|
||||
FakeGitea,
|
||||
RecordingGit,
|
||||
RecordingDevelopment,
|
||||
RecordingPrompts,
|
||||
RecordingOpenCode,
|
||||
]:
|
||||
repository = FakeRepository(implementations=implementations, plan=plan)
|
||||
gitea = FakeGitea(existing_pulls=existing_pulls)
|
||||
git = RecordingGit(changed=changed)
|
||||
development = RecordingDevelopment()
|
||||
prompts = RecordingPrompts()
|
||||
opencode = RecordingOpenCode(
|
||||
AgentResult(
|
||||
summary_markdown="# Implement widget\n\nHandled empty input.",
|
||||
tests=["pytest: passed"],
|
||||
),
|
||||
ReviewReport(summary="Ready", findings=[]),
|
||||
)
|
||||
services = cast(
|
||||
WorkflowServices,
|
||||
SimpleNamespace(
|
||||
settings=SimpleNamespace(
|
||||
branch_prefix="agent",
|
||||
workspaces_dir=tmp_path / "workspaces",
|
||||
gitea_url="https://git.example.test",
|
||||
implement_model="provider/model",
|
||||
implement_variant="high",
|
||||
implement_review_rounds=2,
|
||||
),
|
||||
repository=repository,
|
||||
gitea=gitea,
|
||||
git=git,
|
||||
opencode=opencode,
|
||||
prompts=prompts,
|
||||
development=development,
|
||||
),
|
||||
)
|
||||
return services, repository, gitea, git, development, prompts, opencode
|
||||
|
||||
|
||||
async def test_initial_implementation_completes_review_commit_push_and_pr(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
stale = workflow(pr_number=8)
|
||||
services, repository, gitea, git, development, prompts, opencode = make_services(
|
||||
tmp_path,
|
||||
implementations=[stale],
|
||||
existing_pulls={8: pull(state="closed")},
|
||||
)
|
||||
run = RecordingRun()
|
||||
|
||||
body = await implement(job(), run, services)
|
||||
|
||||
assert len(run.created_workflows) == 1
|
||||
created, created_stage = run.created_workflows[0]
|
||||
assert created_stage == "installing development environment"
|
||||
assert created.status is WorkflowStatus.ACTIVE
|
||||
assert created.branch == f"agent/issue-7-{created.id[:8]}"
|
||||
assert created.workspace_path == tmp_path / "workspaces" / created.id / "repo"
|
||||
assert created.base_sha == "base-sha"
|
||||
assert development.workspaces == [created.workspace_path]
|
||||
assert run.linked_sessions == ["implementation-session"]
|
||||
assert opencode.created_sessions == [
|
||||
(created.workspace_path, "implementation"),
|
||||
(created.workspace_path, "implementation-review"),
|
||||
]
|
||||
assert [call["result_type"] for call in opencode.resume_calls] == [
|
||||
AgentResult,
|
||||
ReviewReport,
|
||||
]
|
||||
|
||||
initial_prompt = prompts.calls[0]
|
||||
assert initial_prompt[0] == "implement_initial"
|
||||
assert initial_prompt[1]["artifact"] == "(no canonical plan)"
|
||||
assert initial_prompt[1]["request"] == "Keep the change focused."
|
||||
assert "Please cover empty input." in initial_prompt[1]["context"]
|
||||
assert "Job queued" not in initial_prompt[1]["context"]
|
||||
|
||||
assert git.calls == [
|
||||
("clone", "org", "repo", "main", created.workspace_path),
|
||||
("create_branch", created.workspace_path, created.branch),
|
||||
("has_changes", created.workspace_path),
|
||||
("diff_check", created.workspace_path),
|
||||
("commit", created.workspace_path, "agent: Implement widget"),
|
||||
("push", created.workspace_path, created.branch, True),
|
||||
]
|
||||
assert gitea.created_pulls == [
|
||||
(
|
||||
"org",
|
||||
"repo",
|
||||
{
|
||||
"title": "Agent: Fix widget",
|
||||
"body": (
|
||||
"Closes #7\n\n"
|
||||
"## Implementation\n\n# Implement widget\n\nHandled empty input.\n\n"
|
||||
"## Validation\n\n- pytest: passed\n\n_Created by Agent CI._"
|
||||
),
|
||||
"head": created.branch,
|
||||
"base": "main",
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
completed = repository.saved_workflows[-1]
|
||||
assert completed.id == created.id
|
||||
assert completed.status is WorkflowStatus.COMPLETED
|
||||
assert completed.pr_number == 42
|
||||
assert completed.primary_session_id == "implementation-session"
|
||||
assert completed.reviewer_session_id == "implementation-review-session"
|
||||
assert AgentResult.model_validate_json(completed.artifact or "").tests == ["pytest: passed"]
|
||||
assert json.loads(completed.review_json or "") == {
|
||||
"summary": "Ready",
|
||||
"findings": [],
|
||||
}
|
||||
assert body == (
|
||||
f"<!-- agentci:implementation workflow={created.id} -->\n"
|
||||
"Pull request created: https://git.example.test/org/repo/pulls/42\n\n"
|
||||
"## Agent result\n\n# Implement widget\n\nHandled empty input.\n\n"
|
||||
"## Validation\n\n- pytest: passed\n\nCommit: `commit-sha`"
|
||||
)
|
||||
|
||||
|
||||
async def test_initial_implementation_rejects_clean_worktree_before_commit_or_pr(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
services, repository, gitea, git, _, _, _ = make_services(tmp_path, changed=False)
|
||||
run = RecordingRun()
|
||||
|
||||
with pytest.raises(
|
||||
JobRejected,
|
||||
match="OpenCode completed without producing any file changes",
|
||||
):
|
||||
await implement(job(), run, services)
|
||||
|
||||
assert [call[0] for call in git.calls] == [
|
||||
"clone",
|
||||
"create_branch",
|
||||
"has_changes",
|
||||
]
|
||||
assert gitea.created_pulls == []
|
||||
assert repository.saved_workflows
|
||||
assert all(item.status is WorkflowStatus.ACTIVE for item in repository.saved_workflows)
|
||||
assert "creating pull request" not in run.stages
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("existing_pull", "message"),
|
||||
[
|
||||
(
|
||||
pull(state="open"),
|
||||
"Agent PR #8 is already open. Use `/agent iterate` on that pull request.",
|
||||
),
|
||||
(
|
||||
pull(state="closed", merged=True),
|
||||
"Agent PR #8 has already been merged for this issue.",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_initial_implementation_rejects_duplicate_agent_pr_before_clone(
|
||||
tmp_path: Path,
|
||||
existing_pull: PullRequestInfo,
|
||||
message: str,
|
||||
) -> None:
|
||||
services, _, gitea, git, development, _, opencode = make_services(
|
||||
tmp_path,
|
||||
implementations=[workflow(pr_number=8)],
|
||||
existing_pulls={8: existing_pull},
|
||||
)
|
||||
|
||||
with pytest.raises(JobRejected) as error:
|
||||
await implement(job(), RecordingRun(), services)
|
||||
|
||||
assert str(error.value) == message
|
||||
assert git.calls == []
|
||||
assert development.workspaces == []
|
||||
assert opencode.resume_calls == []
|
||||
assert gitea.default_branch_calls == []
|
||||
@@ -0,0 +1,439 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agentci.engine.model import Job, JobKind, Workflow, WorkflowKind, WorkflowStatus
|
||||
from agentci.engine.run import JobRun
|
||||
from agentci.gitea import CommentInfo, IssueInfo, PullRequestInfo
|
||||
from agentci.workflows.model import DiscussionReply, PlanArtifact, ReviewReport
|
||||
from agentci.workflows.plan import create_plan, discuss_plan, iterate_plan
|
||||
from agentci.workflows.render import JobRejected
|
||||
from agentci.workflows.services import WorkflowServices
|
||||
|
||||
|
||||
class RecordingRun(JobRun):
|
||||
def __init__(self) -> None:
|
||||
self.stages: list[str] = []
|
||||
self.created_workflows: list[tuple[Workflow, str]] = []
|
||||
self.linked_workflows: list[tuple[str, str]] = []
|
||||
self.linked_sessions: list[str] = []
|
||||
|
||||
async def stage(self, stage: str) -> None:
|
||||
self.stages.append(stage)
|
||||
|
||||
async def create_workflow(self, workflow: Workflow, stage: str) -> None:
|
||||
self.created_workflows.append((workflow, stage))
|
||||
|
||||
async def link_workflow(self, workflow_id: str, stage: str) -> None:
|
||||
self.linked_workflows.append((workflow_id, stage))
|
||||
|
||||
async def link_session(self, session_id: str) -> None:
|
||||
self.linked_sessions.append(session_id)
|
||||
|
||||
|
||||
class FakeRepository:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
latest: Workflow | None = None,
|
||||
implementations: list[Workflow] | None = None,
|
||||
) -> None:
|
||||
self.latest = latest
|
||||
self.implementations = implementations or []
|
||||
self.saved_workflows: list[Workflow] = []
|
||||
self.latest_calls = 0
|
||||
|
||||
async def latest_workflow(self, *_args: object) -> Workflow | None:
|
||||
self.latest_calls += 1
|
||||
return self.latest
|
||||
|
||||
async def implementation_workflows(self, *_args: object) -> list[Workflow]:
|
||||
return self.implementations
|
||||
|
||||
async def operational_comment_ids(self, *_args: object) -> set[int]:
|
||||
return {91}
|
||||
|
||||
async def save_workflow(self, workflow: Workflow) -> None:
|
||||
self.saved_workflows.append(workflow)
|
||||
self.latest = workflow
|
||||
|
||||
|
||||
class FakeGitea:
|
||||
def __init__(self, pulls: dict[int, PullRequestInfo] | None = None) -> None:
|
||||
self.pulls = pulls or {}
|
||||
self.default_branch_calls: list[tuple[str, str]] = []
|
||||
self.pull_calls: list[int] = []
|
||||
|
||||
async def default_branch(self, owner: str, repo: str) -> str:
|
||||
self.default_branch_calls.append((owner, repo))
|
||||
return "trunk"
|
||||
|
||||
async def issue(self, *_args: object) -> IssueInfo:
|
||||
return IssueInfo(3, "Plan feature", "Build the feature.", "open")
|
||||
|
||||
async def issue_comments(self, *_args: object) -> list[CommentInfo]:
|
||||
return [
|
||||
CommentInfo(4, "alice", "Use the existing API.", "2026-07-01"),
|
||||
CommentInfo(91, "agentci", "Job queued", "2026-07-02"),
|
||||
]
|
||||
|
||||
async def pull_request(self, _owner: str, _repo: str, number: int) -> PullRequestInfo:
|
||||
self.pull_calls.append(number)
|
||||
return self.pulls[number]
|
||||
|
||||
|
||||
class RecordingGit:
|
||||
def __init__(self) -> None:
|
||||
self.clone_calls: list[tuple[str, str, str, Path]] = []
|
||||
|
||||
async def clone(self, owner: str, repo: str, branch: str, destination: Path) -> str:
|
||||
self.clone_calls.append((owner, repo, branch, destination))
|
||||
return "base-sha"
|
||||
|
||||
|
||||
class RecordingPrompts:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, dict[str, str]]] = []
|
||||
|
||||
def render(self, name: str, **values: str) -> str:
|
||||
self.calls.append((name, values))
|
||||
return f"rendered {name}"
|
||||
|
||||
|
||||
class RecordingOpenCode:
|
||||
def __init__(self, responses: list[BaseModel] | None = None) -> None:
|
||||
self.responses = list(responses or [])
|
||||
self.created_sessions: list[tuple[Path, str]] = []
|
||||
self.resume_calls: list[dict[str, Any]] = []
|
||||
|
||||
async def create_session(self, workspace: Path, title: str) -> str:
|
||||
self.created_sessions.append((workspace, title))
|
||||
return f"{title}-session"
|
||||
|
||||
async def resume(self, **values: Any) -> BaseModel:
|
||||
self.resume_calls.append(values)
|
||||
response = self.responses.pop(0)
|
||||
assert isinstance(response, values["result_type"])
|
||||
return response
|
||||
|
||||
|
||||
def job(kind: JobKind, *, message: str | None = "Please be specific.") -> Job:
|
||||
return Job(
|
||||
id="job-plan",
|
||||
kind=kind,
|
||||
target_key="org/repo:issue:3",
|
||||
repo_owner="org",
|
||||
repo_name="repo",
|
||||
issue_number=3,
|
||||
pr_number=None,
|
||||
requester="alice",
|
||||
comment_id=5,
|
||||
delivery_id="delivery-plan",
|
||||
receive_sequence=1,
|
||||
command_body="/agent plan",
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
def plan_workflow(
|
||||
*,
|
||||
runtime: str = "opencode",
|
||||
primary_session_id: str | None = "primary-session",
|
||||
reviewer_session_id: str | None = "reviewer-session",
|
||||
artifact: str | None = "Original plan",
|
||||
) -> Workflow:
|
||||
return Workflow(
|
||||
id="plan-flow",
|
||||
kind=WorkflowKind.PLAN,
|
||||
repo_owner="org",
|
||||
repo_name="repo",
|
||||
issue_number=3,
|
||||
workspace_path=Path("/workspace/plan"),
|
||||
base_sha="base-sha",
|
||||
runtime=runtime,
|
||||
primary_session_id=primary_session_id,
|
||||
reviewer_session_id=reviewer_session_id,
|
||||
artifact=artifact,
|
||||
review_json='{"summary":"Prior","findings":[]}',
|
||||
status=WorkflowStatus.COMPLETED,
|
||||
)
|
||||
|
||||
|
||||
def implementation_workflow(pr_number: int | None = 9) -> Workflow:
|
||||
return Workflow(
|
||||
id="implementation-flow",
|
||||
kind=WorkflowKind.IMPLEMENT,
|
||||
repo_owner="org",
|
||||
repo_name="repo",
|
||||
issue_number=3,
|
||||
workspace_path=Path("/workspace/implementation"),
|
||||
base_sha="base",
|
||||
pr_number=pr_number,
|
||||
status=WorkflowStatus.COMPLETED,
|
||||
)
|
||||
|
||||
|
||||
def pull(*, state: str = "open", merged: bool = False) -> PullRequestInfo:
|
||||
return PullRequestInfo(
|
||||
number=9,
|
||||
title="Agent implementation",
|
||||
body="Body",
|
||||
state=state,
|
||||
merged=merged,
|
||||
base_branch="trunk",
|
||||
head_branch="agent/feature",
|
||||
head_sha="head",
|
||||
head_owner="org",
|
||||
head_repo="repo",
|
||||
)
|
||||
|
||||
|
||||
def make_services(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
latest: Workflow | None = None,
|
||||
implementations: list[Workflow] | None = None,
|
||||
pulls: dict[int, PullRequestInfo] | None = None,
|
||||
responses: list[BaseModel] | None = None,
|
||||
) -> tuple[
|
||||
WorkflowServices,
|
||||
FakeRepository,
|
||||
FakeGitea,
|
||||
RecordingGit,
|
||||
RecordingPrompts,
|
||||
RecordingOpenCode,
|
||||
]:
|
||||
repository = FakeRepository(latest=latest, implementations=implementations)
|
||||
gitea = FakeGitea(pulls)
|
||||
git = RecordingGit()
|
||||
prompts = RecordingPrompts()
|
||||
opencode = RecordingOpenCode(responses)
|
||||
services = cast(
|
||||
WorkflowServices,
|
||||
SimpleNamespace(
|
||||
settings=SimpleNamespace(
|
||||
workspaces_dir=tmp_path / "workspaces",
|
||||
plan_model="provider/model",
|
||||
plan_variant="high",
|
||||
plan_review_rounds=3,
|
||||
),
|
||||
repository=repository,
|
||||
gitea=gitea,
|
||||
git=git,
|
||||
prompts=prompts,
|
||||
opencode=opencode,
|
||||
),
|
||||
)
|
||||
return services, repository, gitea, git, prompts, opencode
|
||||
|
||||
|
||||
async def test_create_plan_completes_primary_and_independent_review(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
services, repository, gitea, git, prompts, opencode = make_services(
|
||||
tmp_path,
|
||||
responses=[
|
||||
PlanArtifact(plan_markdown="# Complete plan"),
|
||||
ReviewReport(summary="Ready", findings=[]),
|
||||
],
|
||||
)
|
||||
run = RecordingRun()
|
||||
|
||||
body = await create_plan(job(JobKind.PLAN), run, services)
|
||||
|
||||
created, stage = run.created_workflows[0]
|
||||
assert stage == "planning"
|
||||
assert created.kind is WorkflowKind.PLAN
|
||||
assert created.workspace_path == tmp_path / "workspaces" / created.id / "repo"
|
||||
assert git.clone_calls == [("org", "repo", "trunk", created.workspace_path)]
|
||||
assert gitea.default_branch_calls == [("org", "repo")]
|
||||
assert run.linked_sessions == ["plan-session"]
|
||||
assert opencode.created_sessions == [
|
||||
(created.workspace_path, "plan"),
|
||||
(created.workspace_path, "plan-review"),
|
||||
]
|
||||
assert [call["result_type"] for call in opencode.resume_calls] == [
|
||||
PlanArtifact,
|
||||
ReviewReport,
|
||||
]
|
||||
assert prompts.calls[0][0] == "plan_initial"
|
||||
assert prompts.calls[0][1]["request"] == "Please be specific."
|
||||
assert "Use the existing API." in prompts.calls[0][1]["context"]
|
||||
assert "Job queued" not in prompts.calls[0][1]["context"]
|
||||
|
||||
completed = repository.saved_workflows[-1]
|
||||
assert completed.status is WorkflowStatus.COMPLETED
|
||||
assert completed.primary_session_id == "plan-session"
|
||||
assert completed.reviewer_session_id == "plan-review-session"
|
||||
assert completed.artifact == "# Complete plan"
|
||||
assert json.loads(completed.review_json or "") == {
|
||||
"summary": "Ready",
|
||||
"findings": [],
|
||||
}
|
||||
assert body == f"<!-- agentci:plan workflow={created.id} -->\n# Complete plan"
|
||||
|
||||
|
||||
async def test_discuss_plan_resumes_primary_session_without_replacing_artifact(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
existing = plan_workflow()
|
||||
services, repository, _, _, prompts, opencode = make_services(
|
||||
tmp_path,
|
||||
latest=existing,
|
||||
responses=[DiscussionReply(markdown="The API remains compatible.")],
|
||||
)
|
||||
run = RecordingRun()
|
||||
|
||||
body = await discuss_plan(job(JobKind.DISCUSS), run, services)
|
||||
|
||||
assert run.linked_workflows == [(existing.id, "discussing")]
|
||||
assert opencode.created_sessions == []
|
||||
assert opencode.resume_calls[0]["session_id"] == "primary-session"
|
||||
assert opencode.resume_calls[0]["schema_name"] == "discussion.json"
|
||||
assert prompts.calls == [
|
||||
(
|
||||
"discuss",
|
||||
{"artifact": "Original plan", "message": "Please be specific."},
|
||||
)
|
||||
]
|
||||
assert repository.saved_workflows == []
|
||||
assert body == ("<!-- agentci:discussion workflow=plan-flow -->\nThe API remains compatible.")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("latest", "message"),
|
||||
[
|
||||
(None, "No completed plan exists. Start with `/agent plan`."),
|
||||
(
|
||||
plan_workflow(runtime="codex"),
|
||||
"The latest plan predates OpenCode and cannot be resumed; start a new `/agent plan`.",
|
||||
),
|
||||
(
|
||||
plan_workflow(primary_session_id=None),
|
||||
"The latest plan cannot be resumed; start a new `/agent plan`.",
|
||||
),
|
||||
(
|
||||
plan_workflow(artifact=None),
|
||||
"The latest plan cannot be resumed; start a new `/agent plan`.",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_discuss_plan_rejects_missing_or_incompatible_plan(
|
||||
tmp_path: Path,
|
||||
latest: Workflow | None,
|
||||
message: str,
|
||||
) -> None:
|
||||
services, _, _, _, _, opencode = make_services(tmp_path, latest=latest)
|
||||
|
||||
with pytest.raises(JobRejected) as error:
|
||||
await discuss_plan(job(JobKind.DISCUSS), RecordingRun(), services)
|
||||
|
||||
assert str(error.value) == message
|
||||
assert opencode.resume_calls == []
|
||||
|
||||
|
||||
async def test_iterate_plan_revises_then_reuses_reviewer_session(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
existing = plan_workflow()
|
||||
services, repository, gitea, _, prompts, opencode = make_services(
|
||||
tmp_path,
|
||||
latest=existing,
|
||||
implementations=[implementation_workflow(), implementation_workflow(None)],
|
||||
pulls={9: pull(state="closed")},
|
||||
responses=[
|
||||
PlanArtifact(plan_markdown="# Revised plan"),
|
||||
ReviewReport(summary="Ready", findings=[]),
|
||||
],
|
||||
)
|
||||
run = RecordingRun()
|
||||
|
||||
body = await iterate_plan(job(JobKind.ITERATE_PLAN, message=None), run, services)
|
||||
|
||||
assert gitea.pull_calls == [9]
|
||||
assert run.linked_workflows == [(existing.id, "iterating plan")]
|
||||
assert opencode.created_sessions == []
|
||||
assert [call["session_id"] for call in opencode.resume_calls] == [
|
||||
"primary-session",
|
||||
"reviewer-session",
|
||||
]
|
||||
assert [call["result_type"] for call in opencode.resume_calls] == [
|
||||
PlanArtifact,
|
||||
ReviewReport,
|
||||
]
|
||||
iterate_prompt = prompts.calls[0]
|
||||
assert iterate_prompt[0] == "plan_iterate"
|
||||
assert iterate_prompt[1]["artifact"] == "Original plan"
|
||||
assert iterate_prompt[1]["message"] == ("(refine using the latest discussion and prior review)")
|
||||
assert json.loads(iterate_prompt[1]["review"]) == {
|
||||
"summary": "Prior",
|
||||
"findings": [],
|
||||
}
|
||||
completed = repository.saved_workflows[-1]
|
||||
assert completed.artifact == "# Revised plan"
|
||||
assert completed.status is WorkflowStatus.COMPLETED
|
||||
assert body == "<!-- agentci:plan workflow=plan-flow -->\n# Revised plan"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("latest", "message"),
|
||||
[
|
||||
(None, "No completed plan exists. Start with `/agent plan`."),
|
||||
(
|
||||
plan_workflow(runtime="codex"),
|
||||
"The latest plan predates OpenCode; start a new plan.",
|
||||
),
|
||||
(
|
||||
plan_workflow(primary_session_id=None),
|
||||
"The latest plan is missing resumable sessions; start a new plan.",
|
||||
),
|
||||
(
|
||||
plan_workflow(reviewer_session_id=None),
|
||||
"The latest plan is missing resumable sessions; start a new plan.",
|
||||
),
|
||||
(plan_workflow(artifact=None), "The latest plan has no saved artifact."),
|
||||
],
|
||||
)
|
||||
async def test_iterate_plan_rejects_missing_or_incompatible_plan(
|
||||
tmp_path: Path,
|
||||
latest: Workflow | None,
|
||||
message: str,
|
||||
) -> None:
|
||||
services, repository, _, _, _, opencode = make_services(tmp_path, latest=latest)
|
||||
|
||||
with pytest.raises(JobRejected) as error:
|
||||
await iterate_plan(job(JobKind.ITERATE_PLAN), RecordingRun(), services)
|
||||
|
||||
assert str(error.value) == message
|
||||
assert repository.saved_workflows == []
|
||||
assert opencode.resume_calls == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"blocking_pull",
|
||||
[pull(state="open"), pull(state="closed", merged=True)],
|
||||
ids=["open", "merged"],
|
||||
)
|
||||
async def test_iterate_plan_rejects_after_agent_pr_is_open_or_merged(
|
||||
tmp_path: Path, blocking_pull: PullRequestInfo
|
||||
) -> None:
|
||||
services, repository, _, _, _, opencode = make_services(
|
||||
tmp_path,
|
||||
latest=plan_workflow(),
|
||||
implementations=[implementation_workflow()],
|
||||
pulls={9: blocking_pull},
|
||||
)
|
||||
|
||||
with pytest.raises(JobRejected) as error:
|
||||
await iterate_plan(job(JobKind.ITERATE_PLAN), RecordingRun(), services)
|
||||
|
||||
assert str(error.value) == (
|
||||
"Issue plan iteration is disabled because agent PR #9 is open or merged. "
|
||||
"Iterate an open implementation on its PR."
|
||||
)
|
||||
assert repository.latest_calls == 0
|
||||
assert opencode.resume_calls == []
|
||||
@@ -0,0 +1,502 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agentci.engine.model import Job, JobKind, Workflow, WorkflowKind, WorkflowStatus
|
||||
from agentci.engine.run import JobRun
|
||||
from agentci.gitea import CommentInfo, IssueInfo, PullRequestInfo
|
||||
from agentci.workflows.model import AgentResult, ReviewReport
|
||||
from agentci.workflows.pull_request import fix_pull_request, iterate_implementation
|
||||
from agentci.workflows.render import JobRejected
|
||||
from agentci.workflows.services import WorkflowServices
|
||||
|
||||
|
||||
class RecordingRun(JobRun):
|
||||
def __init__(self) -> None:
|
||||
self.stages: list[str] = []
|
||||
self.linked_workflows: list[tuple[str, str]] = []
|
||||
self.linked_sessions: list[str] = []
|
||||
|
||||
async def stage(self, stage: str) -> None:
|
||||
self.stages.append(stage)
|
||||
|
||||
async def link_workflow(self, workflow_id: str, stage: str) -> None:
|
||||
self.linked_workflows.append((workflow_id, stage))
|
||||
|
||||
async def link_session(self, session_id: str) -> None:
|
||||
self.linked_sessions.append(session_id)
|
||||
|
||||
|
||||
class FakeRepository:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
workflow: Workflow | None = None,
|
||||
plan: Workflow | None = None,
|
||||
) -> None:
|
||||
self.workflow = workflow
|
||||
self.plan = plan
|
||||
self.saved_workflows: list[Workflow] = []
|
||||
|
||||
async def workflow_for_pr(self, *_args: object) -> Workflow | None:
|
||||
return self.workflow
|
||||
|
||||
async def latest_workflow(self, *_args: object) -> Workflow | None:
|
||||
return self.plan
|
||||
|
||||
async def operational_comment_ids(self, *_args: object) -> set[int]:
|
||||
return set()
|
||||
|
||||
async def save_workflow(self, workflow: Workflow) -> None:
|
||||
self.workflow = workflow
|
||||
self.saved_workflows.append(workflow)
|
||||
|
||||
|
||||
class FakeGitea:
|
||||
def __init__(self, pull: PullRequestInfo) -> None:
|
||||
self.pull = pull
|
||||
self.pull_calls: list[tuple[str, str, int]] = []
|
||||
|
||||
async def pull_request(self, owner: str, repo: str, number: int) -> PullRequestInfo:
|
||||
self.pull_calls.append((owner, repo, number))
|
||||
return self.pull
|
||||
|
||||
async def issue_comments(self, *_args: object) -> list[CommentInfo]:
|
||||
return [CommentInfo(1, "alice", "Please add coverage.", "2026-07-01")]
|
||||
|
||||
async def pull_reviews(self, *_args: object) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"id": 3,
|
||||
"user": {"login": "bob"},
|
||||
"state": "REQUEST_CHANGES",
|
||||
"body": "Handle empty input.",
|
||||
}
|
||||
]
|
||||
|
||||
async def review_comments(self, *_args: object) -> list[dict[str, Any]]:
|
||||
return [{"path": "src/widget.py", "new_position": 8, "body": "Add a guard."}]
|
||||
|
||||
async def pull_commits(self, *_args: object) -> list[dict[str, Any]]:
|
||||
return [{"sha": "abcdef1234567890", "commit": {"message": "Initial change"}}]
|
||||
|
||||
async def issue(self, *_args: object) -> IssueInfo:
|
||||
return IssueInfo(7, "Fix widget", "The widget is broken.", "open")
|
||||
|
||||
|
||||
class RecordingGit:
|
||||
def __init__(self, *, changed: bool = True) -> None:
|
||||
self.changed = changed
|
||||
self.calls: list[tuple[Any, ...]] = []
|
||||
|
||||
async def clone(self, owner: str, repo: str, branch: str, destination: Path) -> str:
|
||||
self.calls.append(("clone", owner, repo, branch, destination))
|
||||
return "head-sha"
|
||||
|
||||
async def sync_branch(self, workspace: Path, branch: str) -> str:
|
||||
self.calls.append(("sync_branch", workspace, branch))
|
||||
return "head-sha"
|
||||
|
||||
async def has_changes(self, workspace: Path) -> bool:
|
||||
self.calls.append(("has_changes", workspace))
|
||||
return self.changed
|
||||
|
||||
async def diff_check(self, workspace: Path) -> None:
|
||||
self.calls.append(("diff_check", workspace))
|
||||
|
||||
async def commit(self, workspace: Path, message: str) -> str:
|
||||
self.calls.append(("commit", workspace, message))
|
||||
return "new-sha"
|
||||
|
||||
async def push(self, workspace: Path, branch: str, *, set_upstream: bool = False) -> None:
|
||||
self.calls.append(("push", workspace, branch, set_upstream))
|
||||
|
||||
|
||||
class RecordingDevelopment:
|
||||
description = "Python 3.13"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.workspaces: list[Path] = []
|
||||
|
||||
async def prepare(self, workspace: Path) -> None:
|
||||
self.workspaces.append(workspace)
|
||||
|
||||
|
||||
class RecordingPrompts:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, dict[str, str]]] = []
|
||||
|
||||
def render(self, name: str, **values: str) -> str:
|
||||
self.calls.append((name, values))
|
||||
return f"rendered {name}"
|
||||
|
||||
|
||||
class RecordingOpenCode:
|
||||
def __init__(self, responses: list[BaseModel] | None = None) -> None:
|
||||
self.responses = list(responses or [])
|
||||
self.created_sessions: list[tuple[Path, str]] = []
|
||||
self.resume_calls: list[dict[str, Any]] = []
|
||||
|
||||
async def create_session(self, workspace: Path, title: str) -> str:
|
||||
self.created_sessions.append((workspace, title))
|
||||
return f"{title}-session"
|
||||
|
||||
async def resume(self, **values: Any) -> BaseModel:
|
||||
self.resume_calls.append(values)
|
||||
response = self.responses.pop(0)
|
||||
assert isinstance(response, values["result_type"])
|
||||
return response
|
||||
|
||||
|
||||
def job(kind: JobKind, *, pr_number: int | None = 12) -> Job:
|
||||
return Job(
|
||||
id="job-pr",
|
||||
kind=kind,
|
||||
target_key="org/repo:pr:12",
|
||||
repo_owner="org",
|
||||
repo_name="repo",
|
||||
issue_number=7,
|
||||
pr_number=pr_number,
|
||||
requester="alice",
|
||||
comment_id=9,
|
||||
delivery_id="delivery-pr",
|
||||
receive_sequence=1,
|
||||
command_body="/agent iterate",
|
||||
message="Handle the review.",
|
||||
)
|
||||
|
||||
|
||||
def implementation_workflow(
|
||||
*,
|
||||
status: WorkflowStatus = WorkflowStatus.COMPLETED,
|
||||
runtime: str = "opencode",
|
||||
branch: str | None = "agent/issue-7",
|
||||
primary_session_id: str | None = "primary-session",
|
||||
reviewer_session_id: str | None = "reviewer-session",
|
||||
) -> Workflow:
|
||||
return Workflow(
|
||||
id="implementation-flow",
|
||||
kind=WorkflowKind.IMPLEMENT,
|
||||
repo_owner="org",
|
||||
repo_name="repo",
|
||||
issue_number=7,
|
||||
workspace_path=Path("/workspace/implementation"),
|
||||
base_sha="base-sha",
|
||||
runtime=runtime,
|
||||
branch=branch,
|
||||
pr_number=12,
|
||||
primary_session_id=primary_session_id,
|
||||
reviewer_session_id=reviewer_session_id,
|
||||
artifact='{"summary_markdown":"Prior","tests":[]}',
|
||||
review_json='{"summary":"Prior review","findings":[]}',
|
||||
status=status,
|
||||
)
|
||||
|
||||
|
||||
def plan_workflow() -> Workflow:
|
||||
return Workflow(
|
||||
id="plan-flow",
|
||||
kind=WorkflowKind.PLAN,
|
||||
repo_owner="org",
|
||||
repo_name="repo",
|
||||
issue_number=7,
|
||||
workspace_path=Path("/workspace/plan"),
|
||||
base_sha="base",
|
||||
artifact="Canonical plan",
|
||||
status=WorkflowStatus.COMPLETED,
|
||||
)
|
||||
|
||||
|
||||
def pull(
|
||||
*,
|
||||
state: str = "open",
|
||||
merged: bool = False,
|
||||
branch: str = "agent/issue-7",
|
||||
) -> PullRequestInfo:
|
||||
return PullRequestInfo(
|
||||
number=12,
|
||||
title="Fix widget",
|
||||
body="Implementation body",
|
||||
state=state,
|
||||
merged=merged,
|
||||
base_branch="main",
|
||||
head_branch=branch,
|
||||
head_sha="abcdef1234567890",
|
||||
head_owner="contributor",
|
||||
head_repo="fork",
|
||||
)
|
||||
|
||||
|
||||
def make_services(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
workflow: Workflow | None = None,
|
||||
pull_info: PullRequestInfo | None = None,
|
||||
plan: Workflow | None = None,
|
||||
changed: bool = True,
|
||||
responses: list[BaseModel] | None = None,
|
||||
) -> tuple[
|
||||
WorkflowServices,
|
||||
FakeRepository,
|
||||
FakeGitea,
|
||||
RecordingGit,
|
||||
RecordingDevelopment,
|
||||
RecordingPrompts,
|
||||
RecordingOpenCode,
|
||||
]:
|
||||
repository = FakeRepository(workflow=workflow, plan=plan)
|
||||
gitea = FakeGitea(pull_info or pull())
|
||||
git = RecordingGit(changed=changed)
|
||||
development = RecordingDevelopment()
|
||||
prompts = RecordingPrompts()
|
||||
opencode = RecordingOpenCode(responses)
|
||||
services = cast(
|
||||
WorkflowServices,
|
||||
SimpleNamespace(
|
||||
settings=SimpleNamespace(
|
||||
workspaces_dir=tmp_path / "workspaces",
|
||||
implement_model="provider/model",
|
||||
implement_variant="high",
|
||||
),
|
||||
repository=repository,
|
||||
gitea=gitea,
|
||||
git=git,
|
||||
development=development,
|
||||
prompts=prompts,
|
||||
opencode=opencode,
|
||||
),
|
||||
)
|
||||
return services, repository, gitea, git, development, prompts, opencode
|
||||
|
||||
|
||||
def result(summary: str = "# Refine widget") -> AgentResult:
|
||||
return AgentResult(summary_markdown=summary, tests=["pytest: passed"])
|
||||
|
||||
|
||||
async def test_iterate_implementation_reuses_sessions_reviews_commits_and_pushes(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
existing = implementation_workflow()
|
||||
services, repository, _, git, development, prompts, opencode = make_services(
|
||||
tmp_path,
|
||||
workflow=existing,
|
||||
plan=plan_workflow(),
|
||||
responses=[result(), ReviewReport(summary="Ready", findings=[])],
|
||||
)
|
||||
run = RecordingRun()
|
||||
|
||||
body = await iterate_implementation(job(JobKind.ITERATE_IMPLEMENT), run, services)
|
||||
|
||||
assert run.linked_workflows == [(existing.id, "synchronizing branch")]
|
||||
assert development.workspaces == [existing.workspace_path]
|
||||
assert opencode.created_sessions == []
|
||||
assert [call["session_id"] for call in opencode.resume_calls] == [
|
||||
"primary-session",
|
||||
"reviewer-session",
|
||||
]
|
||||
assert [call["result_type"] for call in opencode.resume_calls] == [
|
||||
AgentResult,
|
||||
ReviewReport,
|
||||
]
|
||||
iterate_prompt = prompts.calls[0]
|
||||
assert iterate_prompt[0] == "implementation_iterate"
|
||||
assert iterate_prompt[1]["message"] == "Handle the review."
|
||||
assert "Handle empty input." in iterate_prompt[1]["context"]
|
||||
review_prompt = prompts.calls[1]
|
||||
assert review_prompt[0] == "implementation_review"
|
||||
assert review_prompt[1]["artifact"] == "Canonical plan"
|
||||
assert "The widget is broken." in review_prompt[1]["issue_context"]
|
||||
|
||||
assert git.calls == [
|
||||
("sync_branch", existing.workspace_path, "agent/issue-7"),
|
||||
("has_changes", existing.workspace_path),
|
||||
("diff_check", existing.workspace_path),
|
||||
("commit", existing.workspace_path, "agent iterate: Refine widget"),
|
||||
("push", existing.workspace_path, "agent/issue-7", False),
|
||||
]
|
||||
saved = repository.saved_workflows[-1]
|
||||
assert AgentResult.model_validate_json(saved.artifact or "") == result()
|
||||
assert json.loads(saved.review_json or "") == {
|
||||
"summary": "Ready",
|
||||
"findings": [],
|
||||
}
|
||||
assert body == (
|
||||
"<!-- agentci:iteration workflow=implementation-flow -->\n"
|
||||
"## Agent result\n\n# Refine widget\n\n"
|
||||
"## Validation\n\n- pytest: passed\n\nCommit: `new-sha`"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("pr_number", "existing", "message"),
|
||||
[
|
||||
(None, implementation_workflow(), "This command requires a pull request."),
|
||||
(
|
||||
12,
|
||||
None,
|
||||
"This is not an open agent-created implementation PR. Use `/agent fix`.",
|
||||
),
|
||||
(
|
||||
12,
|
||||
implementation_workflow(status=WorkflowStatus.ACTIVE),
|
||||
"This is not an open agent-created implementation PR. Use `/agent fix`.",
|
||||
),
|
||||
(
|
||||
12,
|
||||
implementation_workflow(runtime="codex"),
|
||||
"The implementation predates OpenCode and cannot be resumed.",
|
||||
),
|
||||
(
|
||||
12,
|
||||
implementation_workflow(primary_session_id=None),
|
||||
"The implementation sessions cannot be resumed.",
|
||||
),
|
||||
(
|
||||
12,
|
||||
implementation_workflow(reviewer_session_id=None),
|
||||
"The implementation sessions cannot be resumed.",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_iterate_implementation_rejects_missing_stale_or_incompatible_workflow(
|
||||
tmp_path: Path,
|
||||
pr_number: int | None,
|
||||
existing: Workflow | None,
|
||||
message: str,
|
||||
) -> None:
|
||||
services, repository, gitea, git, development, _, opencode = make_services(
|
||||
tmp_path, workflow=existing
|
||||
)
|
||||
|
||||
with pytest.raises(JobRejected) as error:
|
||||
await iterate_implementation(
|
||||
job(JobKind.ITERATE_IMPLEMENT, pr_number=pr_number),
|
||||
RecordingRun(),
|
||||
services,
|
||||
)
|
||||
|
||||
assert str(error.value) == message
|
||||
assert repository.saved_workflows == []
|
||||
assert gitea.pull_calls == []
|
||||
assert git.calls == []
|
||||
assert development.workspaces == []
|
||||
assert opencode.resume_calls == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("pull_info", "existing", "message"),
|
||||
[
|
||||
(
|
||||
pull(state="closed"),
|
||||
implementation_workflow(),
|
||||
"Implementation iteration requires an open pull request.",
|
||||
),
|
||||
(
|
||||
pull(branch="renamed-branch"),
|
||||
implementation_workflow(),
|
||||
"The pull request head branch no longer matches its workflow.",
|
||||
),
|
||||
],
|
||||
ids=["closed", "branch-mismatch"],
|
||||
)
|
||||
async def test_iterate_implementation_rejects_closed_or_stale_branch_before_checkout(
|
||||
tmp_path: Path,
|
||||
pull_info: PullRequestInfo,
|
||||
existing: Workflow,
|
||||
message: str,
|
||||
) -> None:
|
||||
services, repository, _, git, development, _, opencode = make_services(
|
||||
tmp_path, workflow=existing, pull_info=pull_info
|
||||
)
|
||||
|
||||
with pytest.raises(JobRejected) as error:
|
||||
await iterate_implementation(job(JobKind.ITERATE_IMPLEMENT), RecordingRun(), services)
|
||||
|
||||
assert str(error.value) == message
|
||||
assert repository.saved_workflows == []
|
||||
assert git.calls == []
|
||||
assert development.workspaces == []
|
||||
assert opencode.resume_calls == []
|
||||
|
||||
|
||||
async def test_fix_pull_request_clones_head_runs_agent_commits_and_pushes(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
services, repository, _, git, development, prompts, opencode = make_services(
|
||||
tmp_path,
|
||||
responses=[result("Fix empty input")],
|
||||
)
|
||||
run = RecordingRun()
|
||||
|
||||
body = await fix_pull_request(job(JobKind.FIX), run, services)
|
||||
|
||||
workspace = tmp_path / "workspaces" / "fix-job-pr" / "repo"
|
||||
assert git.calls == [
|
||||
("clone", "contributor", "fork", "agent/issue-7", workspace),
|
||||
("has_changes", workspace),
|
||||
("diff_check", workspace),
|
||||
("commit", workspace, "agent fix: Fix empty input"),
|
||||
("push", workspace, "agent/issue-7", False),
|
||||
]
|
||||
assert development.workspaces == [workspace]
|
||||
assert opencode.created_sessions == [(workspace, "fix")]
|
||||
assert run.linked_sessions == ["fix-session"]
|
||||
assert opencode.resume_calls[0]["session_id"] == "fix-session"
|
||||
assert opencode.resume_calls[0]["result_type"] is AgentResult
|
||||
assert prompts.calls[0][0] == "fix"
|
||||
assert "`src/widget.py:8`: Add a guard." in prompts.calls[0][1]["context"]
|
||||
assert repository.saved_workflows == []
|
||||
assert body == (
|
||||
"<!-- agentci:fix workflow=job-pr -->\n"
|
||||
"## Agent result\n\nFix empty input\n\n"
|
||||
"## Validation\n\n- pytest: passed\n\nCommit: `new-sha`"
|
||||
)
|
||||
|
||||
|
||||
async def test_fix_pull_request_rejects_clean_worktree_before_push(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
services, _, _, git, _, _, _ = make_services(
|
||||
tmp_path,
|
||||
changed=False,
|
||||
responses=[result("No changes required")],
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
JobRejected,
|
||||
match="OpenCode completed without producing any file changes",
|
||||
):
|
||||
await fix_pull_request(job(JobKind.FIX), RecordingRun(), services)
|
||||
|
||||
assert [call[0] for call in git.calls] == ["clone", "has_changes"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("pr_number", "pull_info", "message"),
|
||||
[
|
||||
(None, pull(), "This command requires a pull request."),
|
||||
(12, pull(state="closed"), "Fixes require an open pull request."),
|
||||
(12, pull(state="closed", merged=True), "Fixes require an open pull request."),
|
||||
],
|
||||
ids=["missing-pr", "closed", "merged"],
|
||||
)
|
||||
async def test_fix_pull_request_requires_open_pr_before_clone(
|
||||
tmp_path: Path,
|
||||
pr_number: int | None,
|
||||
pull_info: PullRequestInfo,
|
||||
message: str,
|
||||
) -> None:
|
||||
services, _, gitea, git, development, _, opencode = make_services(tmp_path, pull_info=pull_info)
|
||||
|
||||
with pytest.raises(JobRejected) as error:
|
||||
await fix_pull_request(job(JobKind.FIX, pr_number=pr_number), RecordingRun(), services)
|
||||
|
||||
assert str(error.value) == message
|
||||
assert git.calls == []
|
||||
assert development.workspaces == []
|
||||
assert opencode.resume_calls == []
|
||||
assert gitea.pull_calls == ([] if pr_number is None else [("org", "repo", 12)])
|
||||
@@ -0,0 +1,117 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from agentci.workflows.model import (
|
||||
AgentResult,
|
||||
ReviewFinding,
|
||||
ReviewReport,
|
||||
ReviewSeverity,
|
||||
)
|
||||
from agentci.workflows.render import (
|
||||
agent_comment,
|
||||
commit_title,
|
||||
final_comment,
|
||||
pull_request_body,
|
||||
report_for_prompt,
|
||||
required_session,
|
||||
result_comment,
|
||||
review_markdown,
|
||||
)
|
||||
|
||||
|
||||
def report() -> ReviewReport:
|
||||
return ReviewReport(
|
||||
summary="One issue remains.",
|
||||
findings=[
|
||||
ReviewFinding(
|
||||
severity=ReviewSeverity.MAJOR,
|
||||
title="Missing validation",
|
||||
detail="The empty input is not checked.",
|
||||
location="src/widget.py:12",
|
||||
recommendation="Reject empty input.",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_review_markdown_renders_user_visible_finding() -> None:
|
||||
assert review_markdown(report()) == (
|
||||
"## Remaining review findings\n\n"
|
||||
"One issue remains.\n\n"
|
||||
"### MAJOR: Missing validation \u2014 `src/widget.py:12`\n"
|
||||
"The empty input is not checked.\n\n"
|
||||
"Recommendation: Reject empty input."
|
||||
)
|
||||
assert review_markdown(ReviewReport(summary="Ready", findings=[])) == ""
|
||||
|
||||
|
||||
def test_agent_and_final_comments_preserve_protocol_marker() -> None:
|
||||
assert agent_comment("plan", "flow-1", "Plan body") == (
|
||||
"<!-- agentci:plan workflow=flow-1 -->\nPlan body"
|
||||
)
|
||||
assert final_comment("plan", "flow-1", "Plan body", report()) == (
|
||||
f"<!-- agentci:plan workflow=flow-1 -->\nPlan body\n\n{review_markdown(report())}"
|
||||
)
|
||||
assert (
|
||||
final_comment("plan", "flow-1", "Plan body", ReviewReport(summary="Ready", findings=[]))
|
||||
== "<!-- agentci:plan workflow=flow-1 -->\nPlan body"
|
||||
)
|
||||
|
||||
|
||||
def test_pull_request_body_and_result_comment_render_validation() -> None:
|
||||
result = AgentResult(
|
||||
summary_markdown="Implemented the widget fix.",
|
||||
tests=["pytest: passed", "ruff: passed"],
|
||||
)
|
||||
|
||||
assert pull_request_body(17, result) == (
|
||||
"Closes #17\n\n"
|
||||
"## Implementation\n\nImplemented the widget fix.\n\n"
|
||||
"## Validation\n\n- pytest: passed\n- ruff: passed\n\n"
|
||||
"_Created by Agent CI._"
|
||||
)
|
||||
assert result_comment(result, sha="abc123") == (
|
||||
"## Agent result\n\nImplemented the widget fix.\n\n"
|
||||
"## Validation\n\n- pytest: passed\n- ruff: passed\n\n"
|
||||
"Commit: `abc123`"
|
||||
)
|
||||
|
||||
|
||||
def test_renderers_report_missing_validation() -> None:
|
||||
result = AgentResult(summary_markdown="Applied the change.", tests=[])
|
||||
|
||||
assert "## Validation\n\n- Not reported" in pull_request_body(2, result)
|
||||
assert result_comment(result) == (
|
||||
"## Agent result\n\nApplied the change.\n\n## Validation\n\n- Not reported"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("markdown", "expected"),
|
||||
[
|
||||
("# Fix widget\n\nDetails", "Fix widget"),
|
||||
("\n## Trim heading \n", "Trim heading"),
|
||||
("\n\t\n", "apply requested changes"),
|
||||
("x" * 80, "x" * 72),
|
||||
],
|
||||
)
|
||||
def test_commit_title_uses_first_content_line(markdown: str, expected: str) -> None:
|
||||
assert commit_title(markdown) == expected
|
||||
|
||||
|
||||
def test_report_for_prompt_formats_json_and_preserves_unstructured_text() -> None:
|
||||
stored = '{"summary":"Ready","findings":[]}'
|
||||
|
||||
formatted = report_for_prompt(stored)
|
||||
|
||||
assert json.loads(formatted) == {"summary": "Ready", "findings": []}
|
||||
assert formatted == '{\n "summary": "Ready",\n "findings": []\n}'
|
||||
assert report_for_prompt("plain text review") == "plain text review"
|
||||
assert report_for_prompt(None) == "(none)"
|
||||
|
||||
|
||||
def test_required_session_returns_value_or_fails_fast() -> None:
|
||||
assert required_session("session-1") == "session-1"
|
||||
with pytest.raises(RuntimeError, match="Expected a persisted OpenCode session ID"):
|
||||
required_session(None)
|
||||
Reference in New Issue
Block a user