rewrite phase 1

This commit is contained in:
2026-07-22 23:10:23 +02:00
parent 7527831af6
commit 98ac4abca1
89 changed files with 9179 additions and 2795 deletions
+129
View File
@@ -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
View File
@@ -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
View File
@@ -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
+4 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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
+72 -2
View File
@@ -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()
+247
View File
@@ -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
View File
@@ -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]
+77
View File
@@ -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
-143
View File
@@ -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"]
+150 -8
View File
@@ -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()
+75
View File
@@ -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",
]
-13
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+50 -3
View File
@@ -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()
+168 -19
View File
@@ -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
+128 -6
View File
@@ -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),
},
)
+109
View File
@@ -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)
+414
View File
@@ -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]
+462
View File
@@ -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]
+123
View File
@@ -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))
+413
View File
@@ -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,
)
+193
View File
@@ -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],
)
+178
View File
@@ -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
+89
View File
@@ -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
-75
View File
@@ -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"))
-156
View File
@@ -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
View File
@@ -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
View File
@@ -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
+93
View File
@@ -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,)
+392
View File
@@ -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 == []
+439
View File
@@ -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 == []
+502
View File
@@ -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)])
+117
View File
@@ -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)