3 Commits
Author SHA1 Message Date
StanPonomarev ce9f1e3d20 refactor tests
Publish container image / Build and push (push) Successful in 32s
2026-07-26 23:49:40 +02:00
StanPonomarev 5ef10d28fe fix compose
Publish container image / Build and push (push) Successful in 33s
2026-07-25 13:16:32 +02:00
StanPonomarev 73243c1191 fix ci
Publish container image / Build and push (push) Successful in 31s
2026-07-25 12:40:02 +02:00
34 changed files with 1548 additions and 1926 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ name: Publish container image
on: on:
push: push:
branches: branches:
- master - main
env: env:
REGISTRY: git.krtss.de REGISTRY: git.krtss.de
+5 -4
View File
@@ -105,14 +105,15 @@ OpenCode configuration. It may require network access and takes longer than the
## Security and operational boundaries ## Security and operational boundaries
- Never commit or expose `.env`, `secrets/`, tokens, passwords, provider credentials, runtime - Never commit or expose `.env`, `secrets/`, tokens, passwords, provider credentials, runtime data
databases, cloned private repositories, or Docker volume contents. Do not send secrets or private directories, databases, cloned private repositories, or Docker volume contents. Do not send
repository content to external search or research services. secrets or private repository content to external search or research services.
- Preserve webhook HMAC verification, bot-comment filtering, requester write-permission checks, and - Preserve webhook HMAC verification, bot-comment filtering, requester write-permission checks, and
secret-file loading. secret-file loading.
- OpenCode is not an OS sandbox. Do not weaken its permissions, enable repository-local configuration - OpenCode is not an OS sandbox. Do not weaken its permissions, enable repository-local configuration
or external skills, expose its server to the host, add privileged/capability settings, or add or external skills, expose its server to the host, add privileged/capability settings, or add
writable host mounts without an explicit security review. writable host mounts beyond the documented `./data/agentci` and `./data/opencode` state directories
without an explicit security review.
- `install-scripts/` is trusted operator code. Keep scripts idempotent, path-safe, and compatible with - `install-scripts/` is trusted operator code. Keep scripts idempotent, path-safe, and compatible with
the sanitized environment documented in `install-scripts/README.md`; never pass Agent CI or Gitea the sanitized environment documented in `install-scripts/README.md`; never pass Agent CI or Gitea
credentials to them. credentials to them.
+27 -19
View File
@@ -46,27 +46,34 @@ recreated.
OpenCode models. OpenCode models.
3. Create `secrets/gitea_token`, `secrets/webhook_secret`, and 3. Create `secrets/gitea_token`, `secrets/webhook_secret`, and
`secrets/opencode_server_password`. Use high-entropy values for both secret/password files. `secrets/opencode_server_password`. Use high-entropy values for both secret/password files.
4. Log in to the Gitea container registry with a personal access token, then pull the image: 4. Create the writable state directories for the non-root container user:
```sh
mkdir -p data/agentci data/opencode
sudo chown -R 10001:10001 data/agentci data/opencode
```
5. Log in to the Gitea container registry with a personal access token, then pull the image:
```sh ```sh
docker login git.krtss.de docker login git.krtss.de
docker compose pull docker compose pull
``` ```
5. Authenticate the configured OpenCode providers before starting the persistent server: 6. Authenticate the configured OpenCode providers before starting the persistent server:
```sh ```sh
docker compose run --rm opencode opencode auth login docker compose run --rm opencode opencode auth login
docker compose run --rm opencode opencode auth list docker compose run --rm opencode opencode auth list
``` ```
6. Start the services: 7. Start the services:
```sh ```sh
docker compose up --no-build -d docker compose up --no-build -d
``` ```
7. In Gitea, create a JSON webhook targeting `http://agentci:8080/webhooks/gitea`. Set the same 8. In Gitea, create a JSON webhook targeting `http://agentci:8080/webhooks/gitea`. Set the same
webhook secret and subscribe to issue comments, PR timeline comments, and PR review comments. webhook secret and subscribe to issue comments, PR timeline comments, and PR review comments.
OpenCode caches provider state. After adding or changing authentication on an already running OpenCode caches provider state. After adding or changing authentication on an already running
@@ -78,7 +85,7 @@ has a connected provider. The worker leaves jobs queued while the runtime is una
### Image publishing ### Image publishing
Every push to `master` runs `.gitea/workflows/publish-image.yaml` and publishes the image as both Every push to `main` runs `.gitea/workflows/publish-image.yaml` and publishes the image as both
`git.krtss.de/stanponomarev/agentci:latest` and `git.krtss.de/stanponomarev/agentci:latest` and
`git.krtss.de/stanponomarev/agentci:<full-commit-sha>`. Configure these repository Actions secrets `git.krtss.de/stanponomarev/agentci:<full-commit-sha>`. Configure these repository Actions secrets
before the first run: before the first run:
@@ -129,10 +136,11 @@ OpenCode permits still has the Unix-level access of that container user, so envi
is only accidental-exposure hygiene and cannot protect readable files from an allowed shell command. is only accidental-exposure hygiene and cannot protect readable files from an allowed shell command.
Docker remains the OS boundary. The services run as non-root without added capabilities, Docker remains the OS boundary. The services run as non-root without added capabilities,
privileged mode, an unconfined seccomp/AppArmor profile, or a nested `bubblewrap` sandbox. The only privileged mode, an unconfined seccomp/AppArmor profile, or a nested `bubblewrap` sandbox. Persistent
host bind mount is the read-only installer directory; there are no state is exposed through writable host bind mounts at `./data/agentci` and `./data/opencode`; protect
writable host filesystem mounts. The OpenCode HTTP server is not published to the host, is password these directories because they contain private repository clones, installed tools, provider state,
protected, and is reachable by Agent CI over an internal Compose network. and resumable sessions. The OpenCode HTTP server is not published to the host, is password protected,
and is reachable by Agent CI over an internal Compose network.
## Development environments ## Development environments
@@ -146,13 +154,13 @@ AGENTCI_PYTHON_VERSION=3.13
AGENTCI_DOTNET_CHANNEL=10.0 AGENTCI_DOTNET_CHANNEL=10.0
``` ```
Every name resolves to a file in `install-scripts/`, mounted read-only at Every name resolves to a file under `/etc/agentci/install-scripts`, copied from `install-scripts/`
`/etc/agentci/install-scripts`. Names cannot contain paths and duplicates are rejected. Installers when the image is built. Rebuild the image after adding, replacing, or removing an installer. Names
run after each implementation clone or branch sync and fail the job on an unknown script, timeout, cannot contain paths and duplicates are rejected. Installers run after each implementation clone or
or non-zero exit. They receive no Agent CI or Gitea secret values in their environment, but remain branch sync and fail the job on an unknown script, timeout, or non-zero exit. They receive no Agent CI
trusted operator code. Tools persist under `/var/lib/agentci/dev-tools`, and OpenCode can read or or Gitea secret values in their environment, but remain trusted operator code. Tools persist under
modify them through shell commands permitted by its active agent policy. See `./data/agentci/dev-tools`, and OpenCode can read or modify them through shell commands permitted by
`install-scripts/README.md` for the script contract. its active agent policy. See `install-scripts/README.md` for the script contract.
Agent CI continues to create branches, validate diffs, commit, and push after OpenCode returns. This Agent CI continues to create branches, validate diffs, commit, and push after OpenCode returns. This
keeps workflow behavior deterministic, but an OpenCode agent with shell permission can still run keeps workflow behavior deterministic, but an OpenCode agent with shell permission can still run
@@ -160,10 +168,10 @@ Git commands itself.
## State and recovery ## State and recovery
The `agentci_data` volume contains SQLite, workflow clones, and installed development runtimes. The `data/agentci` directory contains SQLite, workflow clones, and installed development runtimes.
The `opencode_home` volume contains provider authentication, OpenCode's database, and resumable The `data/opencode` directory contains provider authentication, OpenCode's database, and resumable
sessions. Tea's Gitea token configuration is regenerated in an ephemeral tmpfs and is not copied to sessions. Tea's Gitea token configuration is regenerated in an ephemeral tmpfs and is not copied to
`opencode_home`. Back up both persistent volumes together. `data/opencode`. Back up both persistent directories together.
SQLite stores the current job state, an idempotent event inbox, and durable listener tasks. State SQLite stores the current job state, an idempotent event inbox, and durable listener tasks. State
transitions and workflow creation/linking commit atomically; timestamps are storage metadata rather transitions and workflow creation/linking commit atomically; timestamps are storage metadata rather
-1
View File
@@ -34,7 +34,6 @@ services:
- opencode_server_password - opencode_server_password
volumes: volumes:
- ./data/agentci:/var/lib/agentci - ./data/agentci:/var/lib/agentci
- ./install-scripts:/etc/agentci/install-scripts:ro
tmpfs: tmpfs:
- /run/agentci:mode=1777 - /run/agentci:mode=1777
expose: expose:
+5 -5
View File
@@ -2,10 +2,10 @@
This directory supplies the ready-made `python` and `dotnet` scripts. They are not reserved: This directory supplies the ready-made `python` and `dotnet` scripts. They are not reserved:
modify, replace, or remove them like any other script. Place other trusted executable install modify, replace, or remove them like any other script. Place other trusted executable install
scripts here and add the desired file names to `AGENTCI_INSTALL_SCRIPTS`. Compose mounts the scripts here and add the desired file names to `AGENTCI_INSTALL_SCRIPTS`. The Docker build copies
directory read-only at `/etc/agentci/install-scripts`. Executable files run directly; files without this directory to `/etc/agentci/install-scripts`, so rebuild the image after changing its contents.
executable mode run as POSIX shell scripts through `/bin/sh` so bind mounts do not depend on host Executable files run directly; files without executable mode run as POSIX shell scripts through
file-mode preservation. `/bin/sh`.
Scripts run from the cloned repository with a sanitized environment. They receive: Scripts run from the cloned repository with a sanitized environment. They receive:
@@ -13,7 +13,7 @@ Scripts run from the cloned repository with a sanitized environment. They receiv
- `PATH`: `$DEV_TOOLS_DIR/bin` followed by the service path - `PATH`: `$DEV_TOOLS_DIR/bin` followed by the service path
- `PYTHON_VERSION` and `DOTNET_CHANNEL`: configured built-in runtime versions - `PYTHON_VERSION` and `DOTNET_CHANNEL`: configured built-in runtime versions
`DEV_TOOLS_DIR` is shared by jobs through the `agentci_data` volume. Put downloaded SDK/runtime `DEV_TOOLS_DIR` is shared by jobs through the `data/agentci` bind mount. Put downloaded SDK/runtime
files beneath it and install command wrappers or symlinks into `$DEV_TOOLS_DIR/bin`; that `bin` files beneath it and install command wrappers or symlinks into `$DEV_TOOLS_DIR/bin`; that `bin`
directory is prepended to implementation agents' `PATH`. Environment changes made by a script do directory is prepended to implementation agents' `PATH`. Environment changes made by a script do
not persist into implementation turns. not persist into implementation turns.
View File
+26
View File
@@ -0,0 +1,26 @@
from dataclasses import dataclass
from pathlib import Path
import pytest
from agentci.engine import _sqlite
from agentci.engine.repository import Repository
@pytest.fixture
async def engine_repository(tmp_path: Path) -> Repository:
repository = Repository(tmp_path / "state.sqlite3")
await repository.initialize()
return repository
@dataclass
class SQLiteClock:
now: str = "2026-02-01T00:00:00+00:00"
@pytest.fixture
def sqlite_clock(monkeypatch: pytest.MonkeyPatch) -> SQLiteClock:
clock = SQLiteClock()
monkeypatch.setattr(_sqlite, "now", lambda: clock.now)
return clock
+108 -173
View File
@@ -1,11 +1,10 @@
from collections.abc import Iterable
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any, cast
from pydantic import BaseModel from pydantic import BaseModel
from agentci.engine.model import Workflow, WorkflowKind from agentci.engine.model import Workflow, WorkflowKind
from agentci.engine.run import JobRun
from agentci.workflows.model import ( from agentci.workflows.model import (
AgentResult, AgentResult,
PlanArtifact, PlanArtifact,
@@ -13,21 +12,20 @@ from agentci.workflows.model import (
ReviewReport, ReviewReport,
ReviewSeverity, ReviewSeverity,
) )
from agentci.workflows.review import ( from agentci.workflows.review import review_implementation_loop, review_plan_loop
review_implementation_loop, from tests.workflow_support import WorkflowHarness, make_workflow_harness
review_implementation_once,
review_plan_loop,
review_plan_once,
)
from agentci.workflows.services import WorkflowServices
def serious_report(summary: str = "Needs work") -> ReviewReport: def serious_report(
summary: str = "Needs work",
*,
severity: ReviewSeverity = ReviewSeverity.MAJOR,
) -> ReviewReport:
return ReviewReport( return ReviewReport(
summary=summary, summary=summary,
findings=[ findings=[
ReviewFinding( ReviewFinding(
severity=ReviewSeverity.MAJOR, severity=severity,
title="Missing check", title="Missing check",
detail="A check is absent.", detail="A check is absent.",
recommendation="Add it.", recommendation="Add it.",
@@ -54,46 +52,15 @@ def minor_report() -> ReviewReport:
) )
class RecordingOpenCode: class FakeRepository:
def __init__(self, responses: list[BaseModel]) -> None: def __init__(self, trace: list[tuple[object, ...]] | None = None) -> 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] = [] self.saved_workflows: list[Workflow] = []
self.trace = trace
async def save_workflow(self, workflow: Workflow) -> None: async def save_workflow(self, workflow: Workflow) -> None:
self.saved_workflows.append(workflow) self.saved_workflows.append(workflow)
if self.trace is not None:
self.trace.append(("save_workflow", workflow.reviewer_session_id))
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: def workflow(*, reviewer_session_id: str | None = None) -> Workflow:
@@ -110,23 +77,15 @@ def workflow(*, reviewer_session_id: str | None = None) -> Workflow:
) )
def objects( def review_harness(
responses: list[BaseModel], responses: Iterable[BaseModel],
repository: FakeRepository,
*, *,
trace: list[tuple[object, ...]] | None = None,
plan_rounds: int = 4, plan_rounds: int = 4,
implementation_rounds: int = 3, implementation_rounds: int = 3,
) -> tuple[ ) -> WorkflowHarness:
RecordingOpenCode, return make_workflow_harness(
RecordingRepository,
RecordingPrompts,
WorkflowServices,
]:
opencode = RecordingOpenCode(responses)
repository = RecordingRepository()
prompts = RecordingPrompts()
services = cast(
WorkflowServices,
SimpleNamespace(
settings=SimpleNamespace( settings=SimpleNamespace(
plan_review_rounds=plan_rounds, plan_review_rounds=plan_rounds,
plan_model="provider/plan", plan_model="provider/plan",
@@ -135,21 +94,21 @@ def objects(
implement_model="provider/implement", implement_model="provider/implement",
implement_variant="high", implement_variant="high",
), ),
opencode=opencode,
repository=repository, repository=repository,
prompts=prompts, gitea=object(),
development=SimpleNamespace(description="Python 3.13"), responses=responses,
), trace=trace,
) )
return opencode, repository, prompts, services
async def test_implementation_loop_persists_reviewed_revision_and_stops_clean() -> None: async def test_implementation_loop_persists_reviewed_revision_and_stops_clean() -> None:
revised = AgentResult(summary_markdown="revision 1", tests=["pytest: passed"]) revised = AgentResult(summary_markdown="revision 1", tests=["pytest: passed"])
opencode, repository, prompts, services = objects( repository = FakeRepository()
[serious_report(), revised, clean_report()], implementation_rounds=4 harness = review_harness(
[serious_report(), revised, clean_report()],
repository,
implementation_rounds=4,
) )
run = RecordingRun()
original = workflow() original = workflow()
updated, result, report = await review_implementation_loop( updated, result, report = await review_implementation_loop(
@@ -157,8 +116,8 @@ async def test_implementation_loop_persists_reviewed_revision_and_stops_clean()
"issue context", "issue context",
"canonical plan", "canonical plan",
AgentResult(summary_markdown="initial", tests=[]), AgentResult(summary_markdown="initial", tests=[]),
run, harness.run,
services, harness.services,
) )
assert result == revised assert result == revised
@@ -169,32 +128,36 @@ async def test_implementation_loop_persists_reviewed_revision_and_stops_clean()
assert updated.review_json == clean_report().model_dump_json() assert updated.review_json == clean_report().model_dump_json()
assert repository.saved_workflows[-1] == updated assert repository.saved_workflows[-1] == updated
assert repository.saved_workflows[0].reviewer_session_id == ("implementation-review-session") assert repository.saved_workflows[0].reviewer_session_id == ("implementation-review-session")
assert run.stages == [ assert harness.run.stages == [
"reviewing implementation 1/4", "reviewing implementation 1/4",
"reviewing implementation 2/4", "reviewing implementation 2/4",
] ]
assert opencode.created_sessions == [(original.workspace_path, "implementation-review")] assert harness.opencode.created_sessions == [(original.workspace_path, "implementation-review")]
assert [call["session_id"] for call in opencode.resume_calls] == [ assert [call["session_id"] for call in harness.opencode.resume_calls] == [
"implementation-review-session", "implementation-review-session",
"primary-session", "primary-session",
"implementation-review-session", "implementation-review-session",
] ]
assert [call["result_type"] for call in opencode.resume_calls] == [ assert [call["result_type"] for call in harness.opencode.resume_calls] == [
ReviewReport, ReviewReport,
AgentResult, AgentResult,
ReviewReport, ReviewReport,
] ]
assert [name for name, _ in prompts.calls] == [ assert [name for name, _ in harness.prompts.calls] == [
"implementation_review", "implementation_review",
"implementation_revision", "implementation_revision",
"implementation_review", "implementation_review",
] ]
assert opencode.responses == [] assert harness.opencode.responses == []
async def test_implementation_loop_never_makes_unreviewed_final_revision() -> None: async def test_implementation_loop_never_makes_unreviewed_final_revision() -> None:
final_report = serious_report("Still failing after the last review") final_report = serious_report(
opencode, repository, _, services = objects( "Still failing after the last review",
severity=ReviewSeverity.BLOCKING,
)
repository = FakeRepository()
harness = review_harness(
[ [
serious_report("round 1"), serious_report("round 1"),
AgentResult(summary_markdown="revision 1", tests=[]), AgentResult(summary_markdown="revision 1", tests=[]),
@@ -202,17 +165,17 @@ async def test_implementation_loop_never_makes_unreviewed_final_revision() -> No
AgentResult(summary_markdown="revision 2", tests=[]), AgentResult(summary_markdown="revision 2", tests=[]),
final_report, final_report,
], ],
repository,
implementation_rounds=3, implementation_rounds=3,
) )
run = RecordingRun()
updated, result, report = await review_implementation_loop( updated, result, report = await review_implementation_loop(
workflow(), workflow(),
"issue context", "issue context",
"canonical plan", "canonical plan",
AgentResult(summary_markdown="initial", tests=[]), AgentResult(summary_markdown="initial", tests=[]),
run, harness.run,
services, harness.services,
) )
assert result.summary_markdown == "revision 2" assert result.summary_markdown == "revision 2"
@@ -220,106 +183,101 @@ async def test_implementation_loop_never_makes_unreviewed_final_revision() -> No
assert updated.artifact == result.model_dump_json() assert updated.artifact == result.model_dump_json()
assert updated.review_json == final_report.model_dump_json() assert updated.review_json == final_report.model_dump_json()
assert repository.saved_workflows[-1] == updated 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 harness.opencode.resume_calls] == [
assert [call["result_type"] for call in opencode.resume_calls].count(AgentResult) == 2 ReviewReport,
assert run.stages == [ AgentResult,
ReviewReport,
AgentResult,
ReviewReport,
]
assert harness.run.stages == [
"reviewing implementation 1/3", "reviewing implementation 1/3",
"reviewing implementation 2/3", "reviewing implementation 2/3",
"reviewing implementation 3/3", "reviewing implementation 3/3",
] ]
assert opencode.responses == [] assert harness.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: async def test_plan_loop_revises_serious_finding_then_persists_clean_result() -> None:
revised = PlanArtifact(plan_markdown="Revised plan") revised = PlanArtifact(plan_markdown="Revised plan")
opencode, repository, prompts, services = objects( trace: list[tuple[object, ...]] = []
[serious_report(), revised, clean_report()], plan_rounds=4 repository = FakeRepository(trace)
harness = review_harness(
[serious_report(), revised, clean_report()],
repository,
trace=trace,
plan_rounds=4,
) )
run = RecordingRun()
original = workflow() original = workflow()
initial = PlanArtifact(plan_markdown="Initial plan") initial = PlanArtifact(plan_markdown="Initial plan")
updated, artifact, report = await review_plan_loop( updated, artifact, report = await review_plan_loop(
original, "issue context", initial, run, services original,
"issue context",
initial,
harness.run,
harness.services,
) )
assert artifact is revised assert artifact is revised
assert report == clean_report() assert report == clean_report()
assert original.reviewer_session_id is None
assert updated.reviewer_session_id == "plan-review-session" assert updated.reviewer_session_id == "plan-review-session"
assert updated.artifact == "Revised plan" assert updated.artifact == "Revised plan"
assert updated.review_json == clean_report().model_dump_json() assert updated.review_json == clean_report().model_dump_json()
assert repository.saved_workflows[-1] == updated assert repository.saved_workflows[-1] == updated
assert run.stages == ["reviewing plan 1/4", "reviewing plan 2/4"] assert harness.run.stages == ["reviewing plan 1/4", "reviewing plan 2/4"]
assert [call["session_id"] for call in opencode.resume_calls] == [ assert harness.opencode.created_sessions == [(original.workspace_path, "plan-review")]
assert harness.opencode.resume_calls[0] == {
"session_id": "plan-review-session",
"workspace": original.workspace_path,
"prompt": "rendered plan_review",
"model": "provider/plan",
"variant": "high",
"schema_name": "review.json",
"result_type": ReviewReport,
}
assert harness.prompts.calls[0] == (
"plan_review",
{"context": "issue context", "artifact": "Initial plan"},
)
assert trace[:3] == [
("create_session", "plan-review", "plan-review-session"),
("save_workflow", "plan-review-session"),
("resume", "plan-review-session", ReviewReport),
]
assert [call["session_id"] for call in harness.opencode.resume_calls] == [
"plan-review-session", "plan-review-session",
"primary-session", "primary-session",
"plan-review-session", "plan-review-session",
] ]
assert [name for name, _ in prompts.calls] == [ assert [name for name, _ in harness.prompts.calls] == [
"plan_review", "plan_review",
"plan_revision", "plan_revision",
"plan_review", "plan_review",
] ]
assert opencode.responses == [] assert harness.opencode.responses == []
async def test_plan_loop_stops_at_round_boundary_without_unreviewed_revision() -> None: async def test_plan_loop_stops_at_round_boundary_without_unreviewed_revision() -> None:
final_report = serious_report("round 2") final_report = serious_report("round 2")
opencode, repository, _, services = objects( repository = FakeRepository()
harness = review_harness(
[ [
serious_report("round 1"), serious_report("round 1"),
PlanArtifact(plan_markdown="Only revision"), PlanArtifact(plan_markdown="Only revision"),
final_report, final_report,
], ],
repository,
plan_rounds=2, plan_rounds=2,
) )
run = RecordingRun()
updated, artifact, report = await review_plan_loop( updated, artifact, report = await review_plan_loop(
workflow(), workflow(),
"issue context", "issue context",
PlanArtifact(plan_markdown="Initial plan"), PlanArtifact(plan_markdown="Initial plan"),
run, harness.run,
services, harness.services,
) )
assert artifact.plan_markdown == "Only revision" assert artifact.plan_markdown == "Only revision"
@@ -327,45 +285,22 @@ async def test_plan_loop_stops_at_round_boundary_without_unreviewed_revision() -
assert updated.artifact == "Only revision" assert updated.artifact == "Only revision"
assert updated.review_json == final_report.model_dump_json() assert updated.review_json == final_report.model_dump_json()
assert repository.saved_workflows[-1] == updated assert repository.saved_workflows[-1] == updated
assert [call["result_type"] for call in opencode.resume_calls] == [ assert [call["result_type"] for call in harness.opencode.resume_calls] == [
ReviewReport, ReviewReport,
PlanArtifact, PlanArtifact,
ReviewReport, ReviewReport,
] ]
assert run.stages == ["reviewing plan 1/2", "reviewing plan 2/2"] assert harness.run.stages == ["reviewing plan 1/2", "reviewing plan 2/2"]
assert opencode.responses == [] assert harness.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: async def test_minor_findings_end_review_loop_without_revision() -> None:
opencode, repository, _, services = objects([minor_report()], implementation_rounds=5) repository = FakeRepository()
run = RecordingRun() harness = review_harness(
[minor_report()],
repository,
implementation_rounds=5,
)
initial = AgentResult(summary_markdown="initial", tests=[]) initial = AgentResult(summary_markdown="initial", tests=[])
updated, result, report = await review_implementation_loop( updated, result, report = await review_implementation_loop(
@@ -373,8 +308,8 @@ async def test_minor_findings_end_review_loop_without_revision() -> None:
"issue context", "issue context",
"canonical plan", "canonical plan",
initial, initial,
run, harness.run,
services, harness.services,
) )
assert result is initial assert result is initial
@@ -383,5 +318,5 @@ async def test_minor_findings_end_review_loop_without_revision() -> None:
assert updated.artifact == initial.model_dump_json() assert updated.artifact == initial.model_dump_json()
assert updated.review_json == minor_report().model_dump_json() assert updated.review_json == minor_report().model_dump_json()
assert repository.saved_workflows[-1] == updated assert repository.saved_workflows[-1] == updated
assert [call["result_type"] for call in opencode.resume_calls] == [ReviewReport] assert [call["result_type"] for call in harness.opencode.resume_calls] == [ReviewReport]
assert run.stages == ["reviewing implementation 1/5"] assert harness.run.stages == ["reviewing implementation 1/5"]
+63 -50
View File
@@ -1,4 +1,7 @@
import asyncio
from collections.abc import Sequence
from pathlib import Path from pathlib import Path
from typing import Any
import pytest import pytest
@@ -14,53 +17,75 @@ class FakeProcess:
return b"", self.stderr return b"", self.stderr
async def test_initializes_incomplete_index_and_excludes_it_from_git( class CodeGraphProcessRecorder:
tmp_path: Path, monkeypatch def __init__(self, outcomes: Sequence[FakeProcess | OSError]) -> None:
) -> None: self.outcomes = list(outcomes)
workspace = tmp_path / "repo" self.calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
(workspace / ".git" / "info").mkdir(parents=True)
(workspace / ".codegraph").mkdir()
calls: list[tuple[object, ...]] = []
async def create_subprocess_exec(*args, **_kwargs): async def __call__(self, *args: Any, **kwargs: Any) -> FakeProcess:
calls.append(args) self.calls.append((args, kwargs))
return FakeProcess() 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 install_recorder(
monkeypatch: pytest.MonkeyPatch,
outcomes: Sequence[FakeProcess | OSError],
) -> CodeGraphProcessRecorder:
recorder = CodeGraphProcessRecorder(outcomes)
monkeypatch.setattr( monkeypatch.setattr(
"agentci.integrations.codegraph.asyncio.create_subprocess_exec", "agentci.integrations.codegraph.asyncio.create_subprocess_exec",
create_subprocess_exec, recorder,
) )
return recorder
await CodeGraph().prepare(workspace)
assert calls == [("codegraph", "init", str(workspace))]
assert (workspace / ".git" / "info" / "exclude").read_text() == ".codegraph/\n"
async def test_syncs_an_existing_index_without_duplicating_exclude( @pytest.mark.parametrize(
tmp_path: Path, monkeypatch ("database_exists", "initial_exclude", "expected_command", "expected_exclude"),
[
pytest.param(False, None, "init", ".codegraph/\n", id="incomplete-index"),
pytest.param(
True,
"# local excludes\n.codegraph/\n",
"sync",
"# local excludes\n.codegraph/\n",
id="existing-index",
),
],
)
async def test_prepare_selects_init_or_sync_and_updates_git_exclude(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
database_exists: bool,
initial_exclude: str | None,
expected_command: str,
expected_exclude: str,
) -> None: ) -> None:
workspace = tmp_path / "repo" workspace = tmp_path / "repo"
(workspace / ".git" / "info").mkdir(parents=True) (workspace / ".git" / "info").mkdir(parents=True)
(workspace / ".codegraph").mkdir() (workspace / ".codegraph").mkdir()
if database_exists:
(workspace / ".codegraph" / "codegraph.db").touch() (workspace / ".codegraph" / "codegraph.db").touch()
exclude = workspace / ".git" / "info" / "exclude" exclude = workspace / ".git" / "info" / "exclude"
exclude.write_text("# local excludes\n.codegraph/\n") if initial_exclude is not None:
calls: list[tuple[object, ...]] = [] exclude.write_text(initial_exclude)
recorder = install_recorder(monkeypatch, [FakeProcess()])
async def create_subprocess_exec(*args, **_kwargs):
calls.append(args)
return FakeProcess()
monkeypatch.setattr(
"agentci.integrations.codegraph.asyncio.create_subprocess_exec",
create_subprocess_exec,
)
await CodeGraph().prepare(workspace) await CodeGraph().prepare(workspace)
assert calls == [("codegraph", "sync", str(workspace))] assert recorder.commands == [("codegraph", expected_command, str(workspace))]
assert exclude.read_text() == "# local excludes\n.codegraph/\n" kwargs = recorder.calls[0][1]
assert kwargs["cwd"] == workspace
assert kwargs["env"]["CODEGRAPH_TELEMETRY"] == "0"
assert kwargs["stdout"] is asyncio.subprocess.PIPE
assert kwargs["stderr"] is asyncio.subprocess.PIPE
assert exclude.read_text() == expected_exclude
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -82,13 +107,7 @@ async def test_handles_exclude_file_boundaries(
exclude.parent.mkdir(parents=True) exclude.parent.mkdir(parents=True)
exclude.write_text(initial) exclude.write_text(initial)
async def create_subprocess_exec(*_args, **_kwargs): install_recorder(monkeypatch, [FakeProcess()])
return FakeProcess()
monkeypatch.setattr(
"agentci.integrations.codegraph.asyncio.create_subprocess_exec",
create_subprocess_exec,
)
await CodeGraph().prepare(workspace) await CodeGraph().prepare(workspace)
@@ -99,12 +118,9 @@ async def test_reports_missing_executable(tmp_path: Path, monkeypatch: pytest.Mo
workspace = tmp_path / "repo" workspace = tmp_path / "repo"
workspace.mkdir() workspace.mkdir()
async def create_subprocess_exec(*_args, **_kwargs): install_recorder(
raise FileNotFoundError(2, "No such file or directory", "codegraph") monkeypatch,
[FileNotFoundError(2, "No such file or directory", "codegraph")],
monkeypatch.setattr(
"agentci.integrations.codegraph.asyncio.create_subprocess_exec",
create_subprocess_exec,
) )
with pytest.raises(CodeGraphError, match="Could not run CodeGraph:.*codegraph") as raised: with pytest.raises(CodeGraphError, match="Could not run CodeGraph:.*codegraph") as raised:
@@ -121,12 +137,9 @@ async def test_reports_nonzero_exit_with_bounded_non_utf8_stderr(
workspace.mkdir() workspace.mkdir()
stderr = b"discarded-prefix" + (b"x" * 1200) + b"\xff useful-tail" stderr = b"discarded-prefix" + (b"x" * 1200) + b"\xff useful-tail"
async def create_subprocess_exec(*_args, **_kwargs): install_recorder(
return FakeProcess(returncode=7, stderr=stderr) monkeypatch,
[FakeProcess(returncode=7, stderr=stderr)],
monkeypatch.setattr(
"agentci.integrations.codegraph.asyncio.create_subprocess_exec",
create_subprocess_exec,
) )
with pytest.raises(CodeGraphError, match="codegraph init failed") as raised: with pytest.raises(CodeGraphError, match="codegraph init failed") as raised:
+53 -27
View File
@@ -8,27 +8,45 @@ def test_ignores_non_commands() -> None:
assert parse_command("please run /agent plan") is None assert parse_command("please run /agent plan") is None
@pytest.mark.parametrize("name", list(CommandName)) @pytest.mark.parametrize(
@pytest.mark.parametrize("line_breaks", [1, 2, 5]) ("name", "is_pull_request", "expected_kind"),
def test_all_commands_accept_messages_after_any_number_of_lines( [
name: CommandName, line_breaks: int (CommandName.PLAN, False, JobKind.PLAN),
) -> None: (CommandName.DISCUSS, False, JobKind.DISCUSS),
command = parse_command( (CommandName.IMPLEMENT, False, JobKind.IMPLEMENT),
f"/agent {name.value}{'\n' * line_breaks}focus on the API\nand add tests" (CommandName.ITERATE, False, JobKind.ITERATE_PLAN),
(CommandName.ITERATE, True, JobKind.ITERATE_IMPLEMENT),
(CommandName.FIX, True, JobKind.FIX),
],
) )
assert command is not None def test_supported_commands_parse_and_resolve(
assert command.name is name
assert command.message == "focus on the API\nand add tests"
@pytest.mark.parametrize("name", list(CommandName))
def test_all_commands_accept_crlf_separated_multiline_messages(
name: CommandName, name: CommandName,
is_pull_request: bool,
expected_kind: JobKind,
) -> None: ) -> None:
command = parse_command(f"/agent {name.value}\r\n\r\n\r\nfocus on the API\r\nand add tests") command = parse_command(f"/agent {name.value}\nfocus on the API\nand add tests")
assert command is not None assert command is not None
assert command.name is name assert (command.name, command.message) == (
assert command.message == "focus on the API\r\nand add tests" name,
"focus on the API\nand add tests",
)
assert resolve_job_kind(command, is_pull_request=is_pull_request) is expected_kind
@pytest.mark.parametrize(
("separator", "line_breaks"),
[("\n", 1), ("\n", 2), ("\n", 5), ("\r\n", 3)],
)
def test_multiline_messages_accept_line_separators(
separator: str,
line_breaks: int,
) -> None:
message = f"focus on the API{separator}and add tests"
command = parse_command(f"/agent {CommandName.PLAN.value}{separator * line_breaks}{message}")
assert command is not None
assert (command.name, command.message) == (CommandName.PLAN, message)
def test_discuss_requires_message() -> None: def test_discuss_requires_message() -> None:
@@ -36,11 +54,25 @@ def test_discuss_requires_message() -> None:
parse_command("/agent discuss") parse_command("/agent discuss")
def test_rejects_wrong_location() -> None: @pytest.mark.parametrize(
command = parse_command("/agent fix") ("name", "is_pull_request", "error_match"),
[
(CommandName.FIX, False, "pull request"),
(CommandName.PLAN, True, "issue"),
(CommandName.DISCUSS, True, "issue"),
(CommandName.IMPLEMENT, True, "issue"),
],
)
def test_rejects_wrong_location(
name: CommandName,
is_pull_request: bool,
error_match: str,
) -> None:
command = parse_command(f"/agent {name.value} details")
assert command is not None assert command is not None
with pytest.raises(CommandError, match="pull request"):
resolve_job_kind(command, is_pull_request=False) with pytest.raises(CommandError, match=error_match):
resolve_job_kind(command, is_pull_request=is_pull_request)
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -48,10 +80,6 @@ def test_rejects_wrong_location() -> None:
[ [
("/agent iterate", ""), ("/agent iterate", ""),
("/agent iterate refine tests", "refine tests"), ("/agent iterate refine tests", "refine tests"),
(
"/agent iterate\n\nkeep the API stable\nlimit changes to the parser",
"keep the API stable\nlimit changes to the parser",
),
], ],
) )
def test_iterate_accepts_optional_message(body: str, message: str) -> None: def test_iterate_accepts_optional_message(body: str, message: str) -> None:
@@ -59,5 +87,3 @@ def test_iterate_accepts_optional_message(body: str, message: str) -> None:
assert command is not None assert command is not None
assert command.name is CommandName.ITERATE assert command.name is CommandName.ITERATE
assert command.message == message assert command.message == message
assert resolve_job_kind(command, is_pull_request=False) is JobKind.ITERATE_PLAN
assert resolve_job_kind(command, is_pull_request=True) is JobKind.ITERATE_IMPLEMENT
+25 -37
View File
@@ -36,38 +36,24 @@ def test_normalizes_service_urls(field: str) -> None:
@pytest.mark.parametrize( @pytest.mark.parametrize(
("field", "boundaries"), ("field", "accepted", "rejected"),
[ [
("plan_review_rounds", (1, 20)), ("plan_review_rounds", (1, 20), (0, 21)),
("implement_review_rounds", (1, 20)), ("implement_review_rounds", (1, 20), (0, 21)),
("turn_timeout_seconds", (60,)), ("turn_timeout_seconds", (60,), (59,)),
("install_script_timeout_seconds", (1,)), ("install_script_timeout_seconds", (1,), (0,)),
("worker_poll_seconds", (0.1,)), ("worker_poll_seconds", (0.1,), (0.09,)),
("max_concurrent_jobs", (1, 32)), ("max_concurrent_jobs", (1, 32), (0, 33)),
], ],
) )
def test_accepts_documented_numeric_boundaries( def test_enforces_documented_numeric_boundaries(
field: str, boundaries: tuple[int | float, ...] field: str,
accepted: tuple[int | float, ...],
rejected: tuple[int | float, ...],
) -> None: ) -> None:
for boundary in boundaries: for value in accepted:
assert getattr(settings(**{field: boundary}), field) == boundary assert getattr(settings(**{field: value}), field) == value
for value in rejected:
@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): with pytest.raises(ValidationError):
settings(**{field: value}) settings(**{field: value})
@@ -119,21 +105,23 @@ def test_derived_state_paths_follow_data_directory(tmp_path: Path) -> None:
assert value.workspaces_dir == data_dir / "workspaces" 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"]) @pytest.mark.parametrize("value", ["../script", "tools/setup", "python,python"])
def test_rejects_unsafe_or_duplicate_install_scripts(value: str) -> None: def test_rejects_unsafe_or_duplicate_install_scripts(value: str) -> None:
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
settings(install_scripts=value) settings(install_scripts=value)
@pytest.mark.parametrize("value", ["", None, []]) @pytest.mark.parametrize(
def test_empty_install_scripts_disable_setup(value: object) -> None: ("value", "expected"),
assert settings(install_scripts=value).install_scripts == [] [
(" python, dotnet, company-tools, ", ["python", "dotnet", "company-tools"]),
("", []),
(None, []),
([], []),
],
)
def test_normalizes_install_scripts(value: object, expected: list[str]) -> None:
assert settings(install_scripts=value).install_scripts == expected
def test_agent_defaults_select_expected_capacity_and_research_models() -> None: def test_agent_defaults_select_expected_capacity_and_research_models() -> None:
+58 -72
View File
@@ -22,7 +22,7 @@ class FakeRepository:
return {2} return {2}
class FakePullRequestGitea: class PopulatedPullRequestGitea:
async def pull_request(self, *_args): async def pull_request(self, *_args):
return PullRequestInfo( return PullRequestInfo(
number=3, number=3,
@@ -38,29 +38,61 @@ class FakePullRequestGitea:
) )
async def issue_comments(self, *_args): async def issue_comments(self, *_args):
return [CommentInfo(3, "bob", "Please add a test.", "2026-01-03")] return [
CommentInfo(
1,
"alice",
"First timeline comment: Please add a test.",
"2026-01-01",
),
CommentInfo(2, "bob", "Second timeline comment", "2026-01-02"),
]
async def pull_reviews(self, *_args): async def pull_reviews(self, *_args):
return [ return [
{ {
"id": 4, "id": 10,
"user": {"login": "carol"}, "user": {"login": "carol"},
"state": "REQUEST_CHANGES", "state": "REQUEST_CHANGES",
"body": "One issue remains.", "body": "First formal review: One issue remains.",
} },
{
"id": 11,
"user": {"login": "dave"},
"state": "APPROVED",
"body": "Second formal review",
},
] ]
async def review_comments(self, *_args): async def review_comments(self, *_args):
review_id = _args[-1]
if review_id == 10:
return [ 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",
},
{ {
"path": "src/widget.py", "path": "src/widget.py",
"new_position": 12, "new_position": 12,
"body": "Handle the empty value.", "body": "Handle the empty value.",
} },
] ]
async def pull_commits(self, *_args): async def pull_commits(self, *_args):
return [{"sha": "abcdef1234567890", "commit": {"message": "Fix widget"}}] return [
{"sha": "111111111111aaaa", "commit": {"message": "First commit"}},
{"sha": "222222222222bbbb", "commit": {"message": "Second commit"}},
]
class EmptyIssueGitea: class EmptyIssueGitea:
@@ -104,55 +136,6 @@ class EmptyPullRequestGitea:
raise AssertionError("review comments should not be requested without reviews") 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: async def test_issue_context_excludes_operational_comments() -> None:
context = await build_issue_context( context = await build_issue_context(
cast(Gitea, FakeIssueGitea()), cast(Gitea, FakeIssueGitea()),
@@ -167,16 +150,31 @@ async def test_issue_context_excludes_operational_comments() -> None:
assert "Agent job queued" not in context assert "Agent job queued" not in context
async def test_pull_request_context_includes_feedback_and_commits() -> None: async def test_pull_request_context_includes_ordered_feedback_reviews_and_commits() -> None:
pull, context = await build_pull_request_context( pull, context = await build_pull_request_context(
cast(Gitea, FakePullRequestGitea()), "org", "repo", 3 cast(Gitea, PopulatedPullRequestGitea()), "org", "repo", 3
) )
assert pull.number == 3 assert pull == 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",
)
assert "Please add a test." in context assert "Please add a test." in context
assert "One issue remains." in context assert "One issue remains." in context
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
assert "`src/widget.py:12`: Handle the empty value." 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: async def test_issue_context_labels_empty_body_and_discussion() -> None:
@@ -201,15 +199,3 @@ async def test_pull_request_context_labels_empty_sections() -> None:
assert "## Commits\n(none)" in context assert "## Commits\n(none)" in context
assert "## Timeline discussion\n(none)" in context assert "## Timeline discussion\n(none)" in context
assert "## Formal and inline reviews\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
+55 -65
View File
@@ -9,7 +9,7 @@ from agentci.integrations.development import (
) )
def environment( def development_environment(
tmp_path: Path, scripts: list[str], *, timeout_seconds: int = 5 tmp_path: Path, scripts: list[str], *, timeout_seconds: int = 5
) -> DevelopmentEnvironment: ) -> DevelopmentEnvironment:
scripts_dir = tmp_path / "scripts" scripts_dir = tmp_path / "scripts"
@@ -24,22 +24,29 @@ def environment(
) )
def script(path: Path, body: str) -> None: def shell_script(path: Path, body: str, *, executable: bool = True) -> None:
path.write_text(f"#!/bin/sh\nset -eu\n{body}\n") path.write_text(f"#!/bin/sh\nset -eu\n{body}\n")
path.chmod(0o755) path.chmod(0o755 if executable else 0o644)
@pytest.fixture
def workspace(tmp_path: Path) -> Path:
path = tmp_path / "workspace"
path.mkdir()
return path
async def test_runs_custom_scripts_in_order_with_sanitized_environment( async def test_runs_custom_scripts_in_order_with_sanitized_environment(
tmp_path, monkeypatch tmp_path: Path,
workspace: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
workspace = tmp_path / "workspace" development = development_environment(tmp_path, ["first", "second"])
workspace.mkdir() shell_script(
development = environment(tmp_path, ["first", "second"])
script(
development.scripts_dir / "first", 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") shell_script(development.scripts_dir / "second", "printf 'second\\n' >> order")
monkeypatch.setenv("AGENTCI_SECRET", "must-not-leak") monkeypatch.setenv("AGENTCI_SECRET", "must-not-leak")
await development.prepare(workspace) await development.prepare(workspace)
@@ -51,24 +58,16 @@ async def test_runs_custom_scripts_in_order_with_sanitized_environment(
assert (tmp_path / "tools" / "bin").is_dir() assert (tmp_path / "tools" / "bin").is_dir()
async def test_supplied_script_names_use_the_same_directory(tmp_path) -> None: async def test_runs_non_executable_shell_script_from_bind_mount(
workspace = tmp_path / "workspace" tmp_path: Path,
workspace.mkdir() workspace: Path,
development = environment(tmp_path, ["python"]) ) -> None:
script(development.scripts_dir / "python", "printf 'python\\n' > selected") development = development_environment(tmp_path, ["mounted"])
shell_script(
await development.prepare(workspace) development.scripts_dir / "mounted",
"printf 'mounted\\n' > selected",
assert (workspace / "selected").read_text() == "python\n" executable=False,
)
async def test_runs_non_executable_shell_script_from_bind_mount(tmp_path) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
development = environment(tmp_path, ["mounted"])
mounted = development.scripts_dir / "mounted"
mounted.write_text("#!/bin/sh\nprintf 'mounted\\n' > selected\n")
mounted.chmod(0o644)
await development.prepare(workspace) await development.prepare(workspace)
@@ -76,8 +75,8 @@ async def test_runs_non_executable_shell_script_from_bind_mount(tmp_path) -> Non
async def test_serializes_concurrent_preparation(tmp_path, monkeypatch) -> None: async def test_serializes_concurrent_preparation(tmp_path, monkeypatch) -> None:
development = environment(tmp_path, ["shared"]) development = development_environment(tmp_path, ["shared"])
script(development.scripts_dir / "shared", "true") shell_script(development.scripts_dir / "shared", "true")
started = asyncio.Event() started = asyncio.Event()
release = asyncio.Event() release = asyncio.Event()
active = 0 active = 0
@@ -102,20 +101,8 @@ async def test_serializes_concurrent_preparation(tmp_path, monkeypatch) -> None:
assert maximum_active == 1 assert maximum_active == 1
async def test_reports_script_failure_output(tmp_path) -> None: async def test_reports_missing_install_script(tmp_path: Path, workspace: Path) -> None:
workspace = tmp_path / "workspace" development = development_environment(tmp_path, ["missing"])
workspace.mkdir()
development = environment(tmp_path, ["broken"])
script(development.scripts_dir / "broken", "printf 'failed detail' >&2; exit 7")
with pytest.raises(DevelopmentEnvironmentError, match="exited with 7: failed detail"):
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( with pytest.raises(
DevelopmentEnvironmentError, DevelopmentEnvironmentError,
@@ -124,25 +111,30 @@ async def test_reports_missing_install_script(tmp_path: Path) -> None:
await development.prepare(workspace) await development.prepare(workspace)
async def test_stops_before_second_script_after_failure(tmp_path: Path) -> None: async def test_stops_after_failure_and_reports_output(
workspace = tmp_path / "workspace" tmp_path: Path,
workspace.mkdir() workspace: Path,
development = environment(tmp_path, ["first", "second"]) ) -> None:
script(development.scripts_dir / "first", "printf 'first\n' > first-ran; exit 3") development = development_environment(tmp_path, ["first", "second"])
script(development.scripts_dir / "second", "printf 'second\n' > second-ran") shell_script(
development.scripts_dir / "first",
"printf 'first\\n' > first-ran; printf 'failed detail' >&2; exit 7",
)
shell_script(development.scripts_dir / "second", "printf 'second\\n' > second-ran")
with pytest.raises(DevelopmentEnvironmentError, match="exited with 3"): with pytest.raises(DevelopmentEnvironmentError, match="exited with 7: failed detail"):
await development.prepare(workspace) await development.prepare(workspace)
assert (workspace / "first-ran").read_text() == "first\n" assert (workspace / "first-ran").read_text() == "first\n"
assert not (workspace / "second-ran").exists() assert not (workspace / "second-ran").exists()
async def test_failure_output_keeps_only_bounded_tail(tmp_path: Path) -> None: async def test_failure_output_keeps_only_bounded_tail(
workspace = tmp_path / "workspace" tmp_path: Path,
workspace.mkdir() workspace: Path,
development = environment(tmp_path, ["verbose"]) ) -> None:
script( development = development_environment(tmp_path, ["verbose"])
shell_script(
development.scripts_dir / "verbose", development.scripts_dir / "verbose",
"printf 'discarded-prefix' >&2; " "printf 'discarded-prefix' >&2; "
'i=0; while [ "$i" -lt 2100 ]; do printf x >&2; i=$((i + 1)); done; ' 'i=0; while [ "$i" -lt 2100 ]; do printf x >&2; i=$((i + 1)); done; '
@@ -158,12 +150,12 @@ async def test_failure_output_keeps_only_bounded_tail(tmp_path: Path) -> None:
async def test_wraps_subprocess_start_error( async def test_wraps_subprocess_start_error(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch tmp_path: Path,
workspace: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
workspace = tmp_path / "workspace" development = development_environment(tmp_path, ["broken"])
workspace.mkdir() shell_script(development.scripts_dir / "broken", "true")
development = environment(tmp_path, ["broken"])
script(development.scripts_dir / "broken", "true")
async def create_subprocess_exec(*_args, **_kwargs): async def create_subprocess_exec(*_args, **_kwargs):
raise OSError("exec unavailable") raise OSError("exec unavailable")
@@ -182,11 +174,9 @@ async def test_wraps_subprocess_start_error(
assert isinstance(raised.value.__cause__, OSError) assert isinstance(raised.value.__cause__, OSError)
async def test_times_out_install_script(tmp_path) -> None: async def test_times_out_install_script(tmp_path: Path, workspace: Path) -> None:
workspace = tmp_path / "workspace" development = development_environment(tmp_path, ["slow"], timeout_seconds=1)
workspace.mkdir() shell_script(development.scripts_dir / "slow", "exec sleep 10")
development = environment(tmp_path, ["slow"], timeout_seconds=1)
script(development.scripts_dir / "slow", "exec sleep 10")
with pytest.raises(DevelopmentEnvironmentError, match="exceeded 1 seconds"): with pytest.raises(DevelopmentEnvironmentError, match="exceeded 1 seconds"):
await development.prepare(workspace) await development.prepare(workspace)
+12 -12
View File
@@ -137,36 +137,36 @@ async def test_sync_branch_resets_and_cleans_before_returning_sha(
assert recorder.calls[0][1]["env"]["AGENTCI_GIT_PASSWORD"] == "secret-token" assert recorder.calls[0][1]["env"]["AGENTCI_GIT_PASSWORD"] == "secret-token"
async def test_branch_status_and_diff_commands( async def test_local_worktree_commands_and_status_mapping(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
recorder = install_recorder( recorder = install_recorder(
monkeypatch, monkeypatch,
[FakeProcess(), FakeProcess(stdout=b" M src/app.py\n"), FakeProcess()], [
FakeProcess(),
FakeProcess(stdout=b" M src/app.py\n"),
FakeProcess(stdout=b" \n"),
FakeProcess(),
],
) )
workspace = tmp_path / "repo" workspace = tmp_path / "repo"
git = git_client(tmp_path) git = git_client(tmp_path)
await git.create_branch(workspace, "agent/issue-1") await git.create_branch(workspace, "agent/issue-1")
changed = await git.has_changes(workspace) dirty = await git.has_changes(workspace)
clean = await git.has_changes(workspace)
await git.diff_check(workspace) await git.diff_check(workspace)
assert changed is True assert dirty is True
assert clean is False
assert recorder.commands == [ assert recorder.commands == [
("git", "switch", "-c", "agent/issue-1"), ("git", "switch", "-c", "agent/issue-1"),
("git", "status", "--porcelain"), ("git", "status", "--porcelain"),
("git", "status", "--porcelain"),
("git", "diff", "--check"), ("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( async def test_commit_stages_all_changes_sets_identity_and_returns_sha(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
+76 -82
View File
@@ -25,6 +25,17 @@ async def gitea_client(handler: Handler, *, retries: int = 3) -> AsyncGenerator[
await client.close() await client.close()
@pytest.fixture
def recorded_backoffs(monkeypatch: pytest.MonkeyPatch) -> list[int]:
backoffs: list[int] = []
async def record_backoff(delay: int) -> None:
backoffs.append(delay)
monkeypatch.setattr("agentci.integrations.gitea.client.asyncio.sleep", record_backoff)
return backoffs
async def test_sends_authenticated_json_request_contract() -> None: async def test_sends_authenticated_json_request_contract() -> None:
requests: list[httpx.Request] = [] requests: list[httpx.Request] = []
@@ -221,19 +232,17 @@ async def test_pagination_stops_only_after_a_short_page(
@pytest.mark.parametrize("status", [400, 401, 403, 404, 422]) @pytest.mark.parametrize("status", [400, 401, 403, 404, 422])
async def test_nonretryable_status_fails_once(status: int, monkeypatch: pytest.MonkeyPatch) -> None: async def test_nonretryable_status_fails_once(
status: int,
recorded_backoffs: list[int],
) -> None:
attempts = 0 attempts = 0
sleeps: list[int] = []
async def handler(_request: httpx.Request) -> httpx.Response: async def handler(_request: httpx.Request) -> httpx.Response:
nonlocal attempts nonlocal attempts
attempts += 1 attempts += 1
return httpx.Response(status) return httpx.Response(status)
async def sleep(delay: int) -> None:
sleeps.append(delay)
monkeypatch.setattr("agentci.integrations.gitea.client.asyncio.sleep", sleep)
async with gitea_client(handler) as client: async with gitea_client(handler) as client:
with pytest.raises( with pytest.raises(
GiteaError, GiteaError,
@@ -242,104 +251,89 @@ async def test_nonretryable_status_fails_once(status: int, monkeypatch: pytest.M
await client.default_branch("org", "repo") await client.default_branch("org", "repo")
assert attempts == 1 assert attempts == 1
assert sleeps == [] assert recorded_backoffs == []
@pytest.mark.parametrize("status", [429, 500, 502, 503, 504]) @pytest.mark.parametrize(
async def test_retryable_status_recovers_after_backoff( ("status", "failures", "expected_backoffs"),
status: int, monkeypatch: pytest.MonkeyPatch [
(429, 1, [1]),
(500, 1, [1]),
(502, 1, [1]),
(503, 1, [1]),
(504, 1, [1]),
pytest.param(None, 2, [1, 2], id="transport"),
],
)
async def test_retryable_failure_recovers_after_exponential_backoff(
status: int | None,
failures: int,
expected_backoffs: list[int],
recorded_backoffs: list[int],
) -> None: ) -> None:
attempts = 0 attempts = 0
sleeps: list[int] = []
async def handler(_request: httpx.Request) -> httpx.Response: async def handler(request: httpx.Request) -> httpx.Response:
nonlocal attempts nonlocal attempts
attempts += 1 attempts += 1
if attempts == 1: if attempts <= failures:
if status is None:
raise httpx.ConnectError("connection refused", request=request)
return httpx.Response(status) return httpx.Response(status)
return httpx.Response(200, json={"default_branch": "main"}) return httpx.Response(200, json={"default_branch": "main"})
async def sleep(delay: int) -> None:
sleeps.append(delay)
monkeypatch.setattr("agentci.integrations.gitea.client.asyncio.sleep", sleep)
async with gitea_client(handler) as client: async with gitea_client(handler) as client:
assert await client.default_branch("org", "repo") == "main" assert await client.default_branch("org", "repo") == "main"
assert attempts == 2 assert attempts == failures + 1
assert sleeps == [1] assert recorded_backoffs == expected_backoffs
async def test_retryable_status_exhaustion_uses_exponential_backoff( @pytest.mark.parametrize(
monkeypatch: pytest.MonkeyPatch, ("status", "retries", "expected_message", "expected_cause", "expected_backoffs"),
[
pytest.param(
503,
3,
"Gitea remained unavailable for GET /repos/org/repo",
None,
[1, 2],
id="status",
),
pytest.param(
None,
2,
"Gitea request failed: GET /repos/org/repo",
httpx.ConnectError,
[1],
id="transport",
),
],
)
async def test_retry_exhaustion_reports_failure(
status: int | None,
retries: int,
expected_message: str,
expected_cause: type[BaseException] | None,
expected_backoffs: list[int],
recorded_backoffs: list[int],
) -> None: ) -> None:
attempts = 0 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.integrations.gitea.client.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: async def handler(request: httpx.Request) -> httpx.Response:
nonlocal attempts nonlocal attempts
attempts += 1 attempts += 1
if attempts < 3: if status is None:
raise httpx.ConnectError("connection refused", request=request) raise httpx.ConnectError("connection refused", request=request)
return httpx.Response(200, json={"default_branch": "main"}) return httpx.Response(status)
async def sleep(delay: int) -> None: async with gitea_client(handler, retries=retries) as client:
sleeps.append(delay) with pytest.raises(GiteaError, match=expected_message) as raised:
monkeypatch.setattr("agentci.integrations.gitea.client.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.integrations.gitea.client.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") await client.default_branch("org", "repo")
assert isinstance(raised.value.__cause__, httpx.ConnectError) if expected_cause is None:
assert attempts == 2 assert raised.value.__cause__ is None
assert sleeps == [1] else:
assert isinstance(raised.value.__cause__, expected_cause)
assert attempts == retries
assert recorded_backoffs == expected_backoffs
+12 -12
View File
@@ -43,7 +43,7 @@ async def test_liveness_does_not_depend_on_runtime_providers() -> None:
@pytest.mark.parametrize( @pytest.mark.parametrize(
("provider_ready", "status_code", "payload"), ("provider_result", "status_code", "payload"),
[ [
(True, 200, {"status": "ready"}), (True, 200, {"status": "ready"}),
( (
@@ -54,24 +54,24 @@ async def test_liveness_does_not_depend_on_runtime_providers() -> None:
"reason": "opencode provider is not connected", "reason": "opencode provider is not connected",
}, },
), ),
pytest.param(
RuntimeError("provider check failed"),
500,
None,
id="provider-error",
),
], ],
) )
async def test_readiness_reflects_provider_state( async def test_readiness_reflects_provider_state(
provider_ready: bool, status_code: int, payload: dict[str, str] provider_result: bool | Exception,
status_code: int,
payload: dict[str, str] | None,
) -> None: ) -> None:
provider = Provider(provider_ready) provider = Provider(provider_result)
response = await get(application(provider), "/health/ready") response = await get(application(provider), "/health/ready")
assert response.status_code == status_code assert response.status_code == status_code
if payload is not None:
assert response.json() == payload assert response.json() == payload
assert provider.calls == 1 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
-172
View File
@@ -121,12 +121,6 @@ async def test_concurrent_readiness_checks_share_one_probe(tmp_path: Path) -> No
PROVIDERS, PROVIDERS,
["/global/health", "/doc"], ["/global/health", "/doc"],
), ),
(
{"healthy": True, "version": "1.18.4"},
API_DOCUMENT,
{**PROVIDERS, "connected": ["anthropic"]},
["/global/health", "/doc", "/provider"],
),
( (
{"healthy": True, "version": "1.18.4"}, {"healthy": True, "version": "1.18.4"},
API_DOCUMENT, API_DOCUMENT,
@@ -349,169 +343,3 @@ async def test_structured_validation_retry_exhaustion_reports_final_error(
assert "without repeating repository work" in prompts[1] assert "without repeating repository work" in prompts[1]
assert paths[-1] == "/session/ses_existing/abort" assert paths[-1] == "/session/ses_existing/abort"
await value.close() 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()
abort_count = 0
async def handler(request: httpx.Request) -> httpx.Response:
nonlocal abort_count
if request.url.path.endswith("/abort"):
abort_count += 1
return httpx.Response(200, json=True)
raise httpx.ReadTimeout("slow", request=request)
value = client(tmp_path, handler)
with pytest.raises(OpenCodeError, match="exceeded 60 seconds"):
await value.resume(
session_id="ses_existing",
workspace=workspace,
prompt="continue",
model="openai/model",
variant=None,
schema_name="agent_result.json",
result_type=AgentResult,
)
assert abort_count == 1
await value.close()
async def test_aborts_cancelled_session(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
started = asyncio.Event()
aborted = False
async def handler(request: httpx.Request) -> httpx.Response:
nonlocal aborted
if request.url.path.endswith("/abort"):
aborted = True
return httpx.Response(200, json=True)
started.set()
await asyncio.Event().wait()
raise AssertionError("unreachable")
value = client(tmp_path, handler)
turn = asyncio.create_task(
value.resume(
session_id="ses_existing",
workspace=workspace,
prompt="continue",
model="openai/model",
variant=None,
schema_name="agent_result.json",
result_type=AgentResult,
)
)
await started.wait()
turn.cancel()
with pytest.raises(asyncio.CancelledError):
await turn
assert aborted
await value.close()
+217 -33
View File
@@ -1,34 +1,61 @@
import asyncio
from pathlib import Path from pathlib import Path
from typing import Any
import httpx import httpx
import pytest import pytest
from agentci.integrations.opencode.client import OpenCode, OpenCodeError from agentci.integrations.opencode.client import OpenCode, OpenCodeError
from agentci.workflows.model import AgentResult
def client(tmp_path: Path, status: int) -> OpenCode: class FakeCodeGraph:
async def prepare(self, _workspace: Path) -> None:
pass
def client(
tmp_path: Path,
handler: Any,
*,
codegraph: FakeCodeGraph | None = None,
timeout_seconds: int = 60,
) -> OpenCode:
selected_codegraph = FakeCodeGraph() if codegraph is None else codegraph
return OpenCode( return OpenCode(
base_url="http://opencode:4096", base_url="http://opencode:4096",
username="opencode", username="opencode",
password="secret", password="secret",
schemas_dir=tmp_path, schemas_dir=Path(__file__).parents[1] / "src" / "agentci" / "prompts" / "schemas",
health_directory=tmp_path, health_directory=tmp_path,
required_models=(), required_models=(),
timeout_seconds=60, timeout_seconds=timeout_seconds,
transport=httpx.MockTransport(lambda _request: httpx.Response(status)), codegraph=selected_codegraph, # type: ignore[arg-type]
transport=httpx.MockTransport(handler),
) )
@pytest.mark.parametrize("status", [200, 204, 404, 409]) @pytest.mark.parametrize(
async def test_absent_or_inactive_session_is_success(tmp_path: Path, status: int) -> None: ("status", "succeeds"),
value = client(tmp_path, status) [
(200, True),
(204, True),
(404, True),
(409, True),
(400, False),
(429, False),
(500, False),
],
)
async def test_abort_status_contract(
tmp_path: Path,
status: int,
succeeds: bool,
) -> None:
value = client(tmp_path, lambda _request: httpx.Response(status))
if succeeds:
await value.abort("session", tmp_path) await value.abort("session", tmp_path)
await value.close() else:
@pytest.mark.parametrize("status", [400, 429, 500])
async def test_abort_failure_is_visible_for_retry(tmp_path: Path, status: int) -> None:
value = client(tmp_path, status)
with pytest.raises(OpenCodeError): with pytest.raises(OpenCodeError):
await value.abort("session", tmp_path) await value.abort("session", tmp_path)
await value.close() await value.close()
@@ -41,16 +68,7 @@ async def test_abort_sends_authenticated_workspace_request(tmp_path: Path) -> No
requests.append(request) requests.append(request)
return httpx.Response(204) return httpx.Response(204)
value = OpenCode( value = client(tmp_path, handler)
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.abort("ses_123", tmp_path)
await value.close() await value.close()
@@ -66,16 +84,182 @@ async def test_best_effort_abort_suppresses_transport_failure(tmp_path: Path) ->
async def handler(request: httpx.Request) -> httpx.Response: async def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("connection refused", request=request) raise httpx.ConnectError("connection refused", request=request)
value = OpenCode( value = client(tmp_path, handler)
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.abort("session", tmp_path, best_effort=True)
await value.close() await value.close()
async def test_cleanup_failure_does_not_mask_turn_error(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
paths: list[str] = []
async def handler(request: httpx.Request) -> httpx.Response:
paths.append(request.url.path)
if request.url.path.endswith("/abort"):
return httpx.Response(500, text="abort failed")
return httpx.Response(200, json={"unexpected": True})
value = client(tmp_path, handler)
with pytest.raises(
OpenCodeError,
match="OpenCode response did not include assistant metadata",
):
await value.resume(
session_id="ses_existing",
workspace=workspace,
prompt="continue",
model="openai/model",
variant=None,
schema_name="agent_result.json",
result_type=AgentResult,
)
assert paths == [
"/session/ses_existing/message",
"/session/ses_existing/abort",
]
await value.close()
async def test_aborts_timed_out_session(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
abort_count = 0
async def handler(request: httpx.Request) -> httpx.Response:
nonlocal abort_count
if request.url.path.endswith("/abort"):
abort_count += 1
return httpx.Response(200, json=True)
raise httpx.ReadTimeout("slow", request=request)
value = client(tmp_path, handler, timeout_seconds=60)
with pytest.raises(OpenCodeError, match="exceeded 60 seconds"):
await value.resume(
session_id="ses_existing",
workspace=workspace,
prompt="continue",
model="openai/model",
variant=None,
schema_name="agent_result.json",
result_type=AgentResult,
)
assert abort_count == 1
await value.close()
async def test_aborts_cancelled_session(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
started = asyncio.Event()
aborted = False
async def handler(request: httpx.Request) -> httpx.Response:
nonlocal aborted
if request.url.path.endswith("/abort"):
aborted = True
return httpx.Response(200, json=True)
started.set()
await asyncio.Event().wait()
raise AssertionError("unreachable")
value = client(tmp_path, handler)
turn = asyncio.create_task(
value.resume(
session_id="ses_existing",
workspace=workspace,
prompt="continue",
model="openai/model",
variant=None,
schema_name="agent_result.json",
result_type=AgentResult,
)
)
await started.wait()
turn.cancel()
with pytest.raises(asyncio.CancelledError):
await turn
assert aborted
await value.close()
async def test_close_aborts_all_active_sessions_and_continues_after_failure(
tmp_path: Path,
) -> None:
failed_workspace = tmp_path / "failed-workspace"
succeeds_workspace = tmp_path / "succeeds-workspace"
failed_workspace.mkdir()
succeeds_workspace.mkdir()
failed_message_started = asyncio.Event()
succeeds_message_started = asyncio.Event()
release_messages = asyncio.Event()
abort_requests: list[httpx.Request] = []
async def handler(request: httpx.Request) -> httpx.Response:
path = request.url.path
if path.endswith("/abort"):
abort_requests.append(request)
return httpx.Response(503 if "/failed/" in path else 204)
if "/failed/" in path:
failed_message_started.set()
summary = "failed-session-finished"
else:
succeeds_message_started.set()
summary = "succeeds-session-finished"
await release_messages.wait()
return httpx.Response(
200,
json={
"info": {"structured": {"summary_markdown": summary, "tests": []}},
"parts": [],
},
)
value = client(tmp_path, handler)
failed_turn = asyncio.create_task(
value.resume(
session_id="failed",
workspace=failed_workspace,
prompt="continue",
model="openai/model",
variant=None,
schema_name="agent_result.json",
result_type=AgentResult,
)
)
await failed_message_started.wait()
succeeds_turn = asyncio.create_task(
value.resume(
session_id="succeeds",
workspace=succeeds_workspace,
prompt="continue",
model="openai/model",
variant=None,
schema_name="agent_result.json",
result_type=AgentResult,
)
)
await succeeds_message_started.wait()
await value.close()
release_messages.set()
completed = await asyncio.gather(failed_turn, succeeds_turn)
assert [request.url for request in abort_requests] == [
httpx.URL("http://opencode:4096/session/failed/abort"),
httpx.URL("http://opencode:4096/session/succeeds/abort"),
]
assert [request.headers["x-opencode-directory"] for request in abort_requests] == [
str(failed_workspace.resolve()),
str(succeeds_workspace.resolve()),
]
assert [result.summary_markdown for result in completed] == [
"failed-session-finished",
"succeeds-session-finished",
]
assert value.client.is_closed
-163
View File
@@ -1,79 +1,9 @@
import json import json
import shutil
import subprocess
from pathlib import Path from pathlib import Path
import pytest
ROOT = Path(__file__).parents[1] 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: def test_config_preserves_builtin_permissions_and_restricts_research() -> None:
config = json.loads((ROOT / "opencode" / "opencode.json").read_text()) config = json.loads((ROOT / "opencode" / "opencode.json").read_text())
@@ -86,60 +16,6 @@ def test_config_preserves_builtin_permissions_and_restricts_research() -> None:
"context7_*": "allow", "context7_*": "allow",
"gh_grep_*": "allow", "gh_grep_*": "allow",
} }
assert config["mcp"]["codegraph"]["command"] == ["codegraph", "serve", "--mcp"]
assert config["mcp"]["context7"]["url"] == "https://mcp.context7.com/mcp"
assert config["agent"]["explore"]["model"] == "{env:AGENTCI_EXPLORE_MODEL}"
assert config["agent"]["explore"]["variant"] == "{env:AGENTCI_EXPLORE_VARIANT}"
assert config["agent"]["research"]["variant"] == "{env:AGENTCI_RESEARCH_VARIANT}"
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")
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",
]
image = "${AGENTCI_IMAGE:-git.krtss.de/stanponomarev/agentci:latest}"
assert _scalar(agentci, "image") == image
assert _scalar(opencode, "image") == image
assert all(line.strip() != "build:" for line in [*agentci, *opencode])
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: def test_compose_has_no_sandbox_security_exceptions() -> None:
@@ -156,20 +32,6 @@ def test_compose_has_no_sandbox_security_exceptions() -> None:
assert forbidden not in compose assert forbidden not in compose
def test_gitea_workflow_publishes_master_images() -> None:
lines = (ROOT / ".gitea" / "workflows" / "publish-image.yaml").read_text().splitlines()
push = _section(_section(lines, "on", indent=0), "push", indent=2)
assert _sequence(push, "branches", indent=4) == ["master"]
assert _mapping(lines, "env", indent=0)["IMAGE_NAME"] == (
"git.krtss.de/stanponomarev/agentci"
)
assert " ${{ env.IMAGE_NAME }}:latest" in lines
assert " ${{ env.IMAGE_NAME }}:${{ gitea.sha }}" in lines
assert " username: ${{ secrets.REGISTRY_USERNAME }}" in lines
assert " password: ${{ secrets.REGISTRY_TOKEN }}" in lines
def test_container_pins_opencode_major_version_contract() -> None: def test_container_pins_opencode_major_version_contract() -> None:
lines = [line.strip() for line in (ROOT / "Dockerfile").read_text().splitlines()] lines = [line.strip() for line in (ROOT / "Dockerfile").read_text().splitlines()]
build_arguments = {line.removeprefix("ARG ") for line in lines if line.startswith("ARG ")} build_arguments = {line.removeprefix("ARG ") for line in lines if line.startswith("ARG ")}
@@ -178,28 +40,3 @@ def test_container_pins_opencode_major_version_contract() -> None:
assert any( assert any(
line.rstrip("\\").strip() == '"opencode-ai@${AGENTCI_OPENCODE_VERSION}"' for line in lines line.rstrip("\\").strip() == '"opencode-ai@${AGENTCI_OPENCODE_VERSION}"' for line in lines
) )
def test_compose_document_validates_when_compose_cli_is_available() -> None:
docker = shutil.which("docker")
if docker is None:
pytest.skip("Docker Compose CLI is not installed")
probe = subprocess.run(
[docker, "compose", "version"],
cwd=ROOT,
capture_output=True,
text=True,
check=False,
)
if probe.returncode:
pytest.skip("Docker Compose provider is not available")
result = subprocess.run(
[docker, "compose", "config", "--quiet"],
cwd=ROOT,
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
+1 -12
View File
@@ -23,7 +23,6 @@ def test_api_contract_requires_session_message_and_abort_routes() -> None:
"document", "document",
[ [
None, None,
[],
{}, {},
{"paths": []}, {"paths": []},
{ {
@@ -33,14 +32,6 @@ def test_api_contract_requires_session_message_and_abort_routes() -> None:
"/session": {"post": {}}, "/session": {"post": {}},
} }
}, },
{
"paths": {
"/global/health": {"get": {}},
"/provider": {"get": {}},
"/session": {"post": {}},
7: {"post": {}},
}
},
], ],
) )
def test_api_contract_rejects_malformed_documents(document: object) -> None: def test_api_contract_rejects_malformed_documents(document: object) -> None:
@@ -78,7 +69,7 @@ def provider_payload(
[ [
(provider_payload(), ("openai", "model", "high"), True), (provider_payload(), ("openai", "model", "high"), True),
(provider_payload(), ("openai", "model", None), True), (provider_payload(), ("openai", "model", None), True),
(provider_payload(connected=("anthropic",)), ("openai", "model", "high"), False), (provider_payload(connected=["anthropic"]), ("openai", "model", "high"), False),
(provider_payload(status="deprecated"), ("openai", "model", "high"), False), (provider_payload(status="deprecated"), ("openai", "model", "high"), False),
(provider_payload(toolcall=False), ("openai", "model", "high"), False), (provider_payload(toolcall=False), ("openai", "model", "high"), False),
(provider_payload(variants={}), ("openai", "model", "high"), False), (provider_payload(variants={}), ("openai", "model", "high"), False),
@@ -95,9 +86,7 @@ def test_model_readiness_matrix(
"payload", "payload",
[ [
None, None,
[],
{}, {},
{"connected": None, "all": []},
{"connected": ["openai"], "all": None}, {"connected": ["openai"], "all": None},
{"connected": ["openai"], "all": [{"id": [], "models": {}}]}, {"connected": ["openai"], "all": [{"id": [], "models": {}}]},
{ {
+17 -20
View File
@@ -105,7 +105,6 @@ def test_command_received_preserves_input_and_requests_authorization() -> None:
("body", "pr_number", "expected_kind", "expected_message"), ("body", "pr_number", "expected_kind", "expected_message"),
[ [
("/agent plan write tests", None, JobKind.PLAN, "write tests"), ("/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"), ("/agent iterate address review", 8, JobKind.ITERATE_IMPLEMENT, "address review"),
], ],
) )
@@ -342,36 +341,34 @@ def test_comment_link_is_allowed_after_a_job_is_terminal() -> None:
@pytest.mark.parametrize( @pytest.mark.parametrize(
("state", "event"), ("state", "event", "error_match"),
[ [
(received(), JobStarted(job_id="job")), (None, PermissionGranted(job_id="job"), "Only CommandReceived"),
(queued(), PermissionGranted(job_id="job")), (
(running(), PermissionGranted(job_id="job")), received(),
PermissionDenied(job_id="another-job"),
"job ID does not match",
),
(received(), JobStarted(job_id="job"), "invalid while job"),
(queued(), PermissionGranted(job_id="job"), "invalid while job"),
(running(), PermissionGranted(job_id="job"), "invalid while job"),
( (
reduce_job(running(), JobCompleted(job_id="job", comment_body="ok")).job, reduce_job(running(), JobCompleted(job_id="job", comment_body="ok")).job,
JobStarted(job_id="job"), JobStarted(job_id="job"),
"invalid while job",
), ),
], ],
ids=["received", "queued", "running", "terminal"], ids=["missing-state", "mismatched-id", "received", "queued", "running", "terminal"],
) )
def test_events_invalid_for_the_current_status_are_rejected( def test_invalid_transitions_are_rejected(
state: Job, state: Job | None,
event: JobStarted | PermissionGranted, event: JobStarted | PermissionDenied | PermissionGranted,
error_match: str,
) -> None: ) -> None:
with pytest.raises(InvalidTransition, match="invalid while job"): with pytest.raises(InvalidTransition, match=error_match):
reduce_job(state, event) 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( @pytest.mark.parametrize(
("state", "expected"), ("state", "expected"),
[ [
+74 -144
View File
@@ -26,8 +26,7 @@ from agentci.engine.model import (
) )
from agentci.engine.reducer import Transition from agentci.engine.reducer import Transition
from agentci.engine.repository import Repository from agentci.engine.repository import Repository
from tests.conftest import SQLiteClock
MIGRATIONS = Path(__file__).parents[1] / "src" / "agentci" / "migrations"
class TrackingConnection: class TrackingConnection:
@@ -56,13 +55,6 @@ class FailingSetupConnection:
self.closed = True 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( def command(
delivery: str, delivery: str,
body: str = "/agent plan", body: str = "/agent plan",
@@ -82,10 +74,12 @@ def command(
) )
async def create_running_job(repository: Repository, delivery: str, *, issue: int = 3) -> Job: async def create_running_job(
job = (await repository.accept(command(delivery, issue=issue))).job engine_repository: Repository, delivery: str, *, issue: int = 3
job = (await repository.apply(f"{delivery}:grant", PermissionGranted(job_id=job.id))).job ) -> Job:
return (await repository.apply(f"{delivery}:start", JobStarted(job_id=job.id))).job job = (await engine_repository.accept(command(delivery, issue=issue))).job
job = (await engine_repository.apply(f"{delivery}:grant", PermissionGranted(job_id=job.id))).job
return (await engine_repository.apply(f"{delivery}:start", JobStarted(job_id=job.id))).job
async def test_repository_closes_connections_after_success_and_failure( async def test_repository_closes_connections_after_success_and_failure(
@@ -98,7 +92,7 @@ async def test_repository_closes_connections_after_success_and_failure(
"connect", "connect",
lambda _path: cast(sqlite3.Connection, connections.pop(0)), lambda _path: cast(sqlite3.Connection, connections.pop(0)),
) )
repository = Repository(tmp_path / "state.sqlite3", MIGRATIONS) repository = Repository(tmp_path / "state.sqlite3")
successful = cast(TrackingConnection, await repository._run(lambda connection: connection)) successful = cast(TrackingConnection, await repository._run(lambda connection: connection))
def fail(_connection: sqlite3.Connection) -> None: def fail(_connection: sqlite3.Connection) -> None:
@@ -154,86 +148,38 @@ def build_workflow(
async def attach_workflow( async def attach_workflow(
repository: Repository, engine_repository: Repository,
workspace: Path, workspace: Path,
*, *,
delivery: str, delivery: str,
workflow_id: str, workflow_id: str,
status: WorkflowStatus = WorkflowStatus.ACTIVE, status: WorkflowStatus = WorkflowStatus.ACTIVE,
) -> tuple[Job, Workflow]: ) -> tuple[Job, Workflow]:
job = await create_running_job(repository, delivery) job = await create_running_job(engine_repository, delivery)
workflow = build_workflow(workflow_id, workspace, status=status) workflow = build_workflow(workflow_id, workspace, status=status)
result = await repository.apply( result = await engine_repository.apply(
f"{workflow_id}:created", f"{workflow_id}:created",
WorkflowCreated(job_id=job.id, workflow=workflow, stage="planning"), WorkflowCreated(job_id=job.id, workflow=workflow, stage="planning"),
) )
return result.job, workflow 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( async def test_repository_stamps_start_and_completion_once(
repository: Repository, engine_repository: Repository,
monkeypatch: pytest.MonkeyPatch, sqlite_clock: SQLiteClock,
) -> None: ) -> None:
clock = {"now": "2026-02-01T00:00:00+00:00"} job = (await engine_repository.accept(command("delivery-1"))).job
monkeypatch.setattr(_sqlite, "now", lambda: clock["now"]) job = (await engine_repository.apply("grant", PermissionGranted(job_id=job.id))).job
job = (await repository.accept(command("delivery-1"))).job sqlite_clock.now = "2026-02-01T00:01:00+00:00"
job = (await repository.apply("grant", PermissionGranted(job_id=job.id))).job job = (await engine_repository.apply("start", JobStarted(job_id=job.id))).job
clock["now"] = "2026-02-01T00:01:00+00:00" sqlite_clock.now = "2026-02-01T00:02:00+00:00"
job = (await repository.apply("start", JobStarted(job_id=job.id))).job job = (
clock["now"] = "2026-02-01T00:02:00+00:00" await engine_repository.apply("complete", JobCompleted(job_id=job.id, comment_body="done"))
job = (await repository.apply("complete", JobCompleted(job_id=job.id, comment_body="done"))).job ).job
clock["now"] = "2026-02-01T00:03:00+00:00" sqlite_clock.now = "2026-02-01T00:03:00+00:00"
await repository.apply("comment", CommentLinked(job_id=job.id, comment_id=99)) await engine_repository.apply("comment", CommentLinked(job_id=job.id, comment_id=99))
with closing(sqlite3.connect(repository.database_path)) as connection, connection: with closing(sqlite3.connect(engine_repository.database_path)) as connection, connection:
timestamps = connection.execute( timestamps = connection.execute(
"SELECT started_at, finished_at FROM jobs WHERE id=?", (job.id,) "SELECT started_at, finished_at FROM jobs WHERE id=?", (job.id,)
).fetchone() ).fetchone()
@@ -245,11 +191,11 @@ async def test_repository_stamps_start_and_completion_once(
async def test_apply_rolls_back_event_job_workflow_and_task_after_mid_apply_failure( async def test_apply_rolls_back_event_job_workflow_and_task_after_mid_apply_failure(
repository: Repository, engine_repository: Repository,
tmp_path: Path, tmp_path: Path,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
job = await create_running_job(repository, "delivery-1") job = await create_running_job(engine_repository, "delivery-1")
workflow = build_workflow("workflow-rollback", tmp_path) workflow = build_workflow("workflow-rollback", tmp_path)
original_insert_tasks = _sqlite.insert_tasks original_insert_tasks = _sqlite.insert_tasks
@@ -271,14 +217,14 @@ async def test_apply_rolls_back_event_job_workflow_and_task_after_mid_apply_fail
monkeypatch.setattr(_sqlite, "insert_tasks", fail_after_task_write) monkeypatch.setattr(_sqlite, "insert_tasks", fail_after_task_write)
with pytest.raises(RuntimeError, match="injected task persistence failure"): with pytest.raises(RuntimeError, match="injected task persistence failure"):
await repository.apply( await engine_repository.apply(
"workflow-rollback:created", "workflow-rollback:created",
WorkflowCreated(job_id=job.id, workflow=workflow, stage="planning"), WorkflowCreated(job_id=job.id, workflow=workflow, stage="planning"),
) )
persisted_job = await repository.get_job(job.id) persisted_job = await engine_repository.get_job(job.id)
persisted_workflow = await repository.get_workflow(workflow.id) persisted_workflow = await engine_repository.get_workflow(workflow.id)
with closing(sqlite3.connect(repository.database_path)) as connection, connection: with closing(sqlite3.connect(engine_repository.database_path)) as connection, connection:
event_count = connection.execute( event_count = connection.execute(
"SELECT COUNT(*) FROM job_events WHERE event_id=?", "SELECT COUNT(*) FROM job_events WHERE event_id=?",
("workflow-rollback:created",), ("workflow-rollback:created",),
@@ -297,13 +243,13 @@ async def test_apply_rolls_back_event_job_workflow_and_task_after_mid_apply_fail
async def test_duplicate_event_application_does_not_duplicate_tasks( async def test_duplicate_event_application_does_not_duplicate_tasks(
repository: Repository, engine_repository: Repository,
) -> None: ) -> None:
job = (await repository.accept(command("delivery-1"))).job job = (await engine_repository.accept(command("delivery-1"))).job
applied = await repository.apply("permission", PermissionGranted(job_id=job.id)) applied = await engine_repository.apply("permission", PermissionGranted(job_id=job.id))
duplicate = await repository.apply("permission", PermissionGranted(job_id=job.id)) duplicate = await engine_repository.apply("permission", PermissionGranted(job_id=job.id))
with closing(sqlite3.connect(repository.database_path)) as connection, connection: with closing(sqlite3.connect(engine_repository.database_path)) as connection, connection:
tasks = connection.execute( tasks = connection.execute(
"""SELECT ordinal, listener, queue FROM listener_tasks """SELECT ordinal, listener, queue FROM listener_tasks
WHERE source_event_id=? ORDER BY ordinal""", WHERE source_event_id=? ORDER BY ordinal""",
@@ -317,30 +263,13 @@ async def test_duplicate_event_application_does_not_duplicate_tasks(
) )
async def test_workflow_creation_and_job_link_commit_together( async def test_save_workflow_updates_the_mutable_snapshot_and_timestamp(
repository: Repository, engine_repository: Repository,
tmp_path: Path, tmp_path: Path,
sqlite_clock: SQLiteClock,
) -> None: ) -> None:
job, workflow = await attach_workflow( job, workflow = await attach_workflow(
repository, engine_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, tmp_path,
delivery="delivery-1", delivery="delivery-1",
workflow_id="workflow-update", workflow_id="workflow-update",
@@ -355,56 +284,57 @@ async def test_save_workflow_updates_the_mutable_snapshot_and_timestamp(
review_json='{"verdict":"approved"}', review_json='{"verdict":"approved"}',
status=WorkflowStatus.COMPLETED, status=WorkflowStatus.COMPLETED,
) )
monkeypatch.setattr(_sqlite, "now", lambda: "2026-02-02T00:00:00+00:00") sqlite_clock.now = "2026-02-02T00:00:00+00:00"
await repository.save_workflow(updated) await engine_repository.save_workflow(updated)
with closing(sqlite3.connect(repository.database_path)) as connection, connection: with closing(sqlite3.connect(engine_repository.database_path)) as connection, connection:
updated_at = connection.execute( updated_at = connection.execute(
"SELECT updated_at FROM workflows WHERE id=?", (workflow.id,) "SELECT updated_at FROM workflows WHERE id=?", (workflow.id,)
).fetchone() ).fetchone()
assert (await repository.get_workflow(workflow.id), updated_at) == ( assert (job.workflow_id, await engine_repository.get_workflow(workflow.id), updated_at) == (
workflow.id,
updated, updated,
("2026-02-02T00:00:00+00:00",), ("2026-02-02T00:00:00+00:00",),
) )
async def test_saving_unknown_workflow_fails(repository: Repository, tmp_path: Path) -> None: async def test_saving_unknown_workflow_fails(engine_repository: Repository, tmp_path: Path) -> None:
workflow = build_workflow("missing", tmp_path) workflow = build_workflow("missing", tmp_path)
with pytest.raises(KeyError, match="Unknown workflow"): with pytest.raises(KeyError, match="Unknown workflow"):
await repository.save_workflow(workflow) await engine_repository.save_workflow(workflow)
async def test_fail_job_workflow_fails_only_active_workflows( async def test_fail_job_workflow_fails_only_active_workflows(
repository: Repository, engine_repository: Repository,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
active_job, active = await attach_workflow( active_job, active = await attach_workflow(
repository, engine_repository,
tmp_path, tmp_path,
delivery="delivery-1", delivery="delivery-1",
workflow_id="active-workflow", workflow_id="active-workflow",
) )
completed_job, completed = await attach_workflow( completed_job, completed = await attach_workflow(
repository, engine_repository,
tmp_path, tmp_path,
delivery="delivery-2", delivery="delivery-2",
workflow_id="completed-workflow", workflow_id="completed-workflow",
status=WorkflowStatus.COMPLETED, status=WorkflowStatus.COMPLETED,
) )
await repository.fail_job_workflow(active_job.id) await engine_repository.fail_job_workflow(active_job.id)
await repository.fail_job_workflow(completed_job.id) await engine_repository.fail_job_workflow(completed_job.id)
assert ( assert (
(await repository.get_workflow(active.id)).status, # type: ignore[union-attr] (await engine_repository.get_workflow(active.id)).status, # type: ignore[union-attr]
(await repository.get_workflow(completed.id)).status, # type: ignore[union-attr] (await engine_repository.get_workflow(completed.id)).status, # type: ignore[union-attr]
) == (WorkflowStatus.FAILED, WorkflowStatus.COMPLETED) ) == (WorkflowStatus.FAILED, WorkflowStatus.COMPLETED)
async def test_latest_workflow_returns_newest_completed_match( async def test_latest_workflow_returns_newest_completed_match(
repository: Repository, engine_repository: Repository,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
older = build_workflow("plan-older", tmp_path, status=WorkflowStatus.COMPLETED) older = build_workflow("plan-older", tmp_path, status=WorkflowStatus.COMPLETED)
@@ -413,17 +343,17 @@ async def test_latest_workflow_returns_newest_completed_match(
wrong_issue = build_workflow( wrong_issue = build_workflow(
"plan-wrong-issue", tmp_path, issue=4, status=WorkflowStatus.COMPLETED "plan-wrong-issue", tmp_path, issue=4, status=WorkflowStatus.COMPLETED
) )
with closing(sqlite3.connect(repository.database_path)) as connection, connection: with closing(sqlite3.connect(engine_repository.database_path)) as connection, connection:
insert_workflow(connection, older, "2026-01-01T00:00:00+00:00") _sqlite.insert_workflow(connection, older, "2026-01-01T00:00:00+00:00")
insert_workflow(connection, newest, "2026-01-02T00:00:00+00:00") _sqlite.insert_workflow(connection, newest, "2026-01-02T00:00:00+00:00")
insert_workflow(connection, active, "2026-01-03T00:00:00+00:00") _sqlite.insert_workflow(connection, active, "2026-01-03T00:00:00+00:00")
insert_workflow(connection, wrong_issue, "2026-01-04T00:00:00+00:00") _sqlite.insert_workflow(connection, wrong_issue, "2026-01-04T00:00:00+00:00")
assert await repository.latest_workflow("alice", "repo", 3, WorkflowKind.PLAN) == newest assert await engine_repository.latest_workflow("alice", "repo", 3, WorkflowKind.PLAN) == newest
async def test_workflow_for_pr_returns_newest_implementation_match( async def test_workflow_for_pr_returns_newest_implementation_match(
repository: Repository, engine_repository: Repository,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
older = build_workflow("implementation-older", tmp_path, kind=WorkflowKind.IMPLEMENT, pr=17) older = build_workflow("implementation-older", tmp_path, kind=WorkflowKind.IMPLEMENT, pr=17)
@@ -436,27 +366,27 @@ async def test_workflow_for_pr_returns_newest_implementation_match(
repo="another", repo="another",
pr=17, pr=17,
) )
with closing(sqlite3.connect(repository.database_path)) as connection, connection: with closing(sqlite3.connect(engine_repository.database_path)) as connection, connection:
insert_workflow(connection, older, "2026-01-01T00:00:00+00:00") _sqlite.insert_workflow(connection, older, "2026-01-01T00:00:00+00:00")
insert_workflow(connection, newest, "2026-01-02T00:00:00+00:00") _sqlite.insert_workflow(connection, newest, "2026-01-02T00:00:00+00:00")
insert_workflow(connection, plan, "2026-01-03T00:00:00+00:00") _sqlite.insert_workflow(connection, plan, "2026-01-03T00:00:00+00:00")
insert_workflow(connection, wrong_repo, "2026-01-04T00:00:00+00:00") _sqlite.insert_workflow(connection, wrong_repo, "2026-01-04T00:00:00+00:00")
assert await repository.workflow_for_pr("alice", "repo", 17) == newest assert await engine_repository.workflow_for_pr("alice", "repo", 17) == newest
async def test_implementation_workflows_are_newest_first_and_require_a_pr( async def test_implementation_workflows_are_newest_first_and_require_a_pr(
repository: Repository, engine_repository: Repository,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
older = build_workflow("implementation-older", tmp_path, kind=WorkflowKind.IMPLEMENT, pr=17) older = build_workflow("implementation-older", tmp_path, kind=WorkflowKind.IMPLEMENT, pr=17)
newest = build_workflow("implementation-newest", tmp_path, kind=WorkflowKind.IMPLEMENT, pr=18) newest = build_workflow("implementation-newest", tmp_path, kind=WorkflowKind.IMPLEMENT, pr=18)
no_pr = build_workflow("implementation-no-pr", tmp_path, kind=WorkflowKind.IMPLEMENT) no_pr = build_workflow("implementation-no-pr", tmp_path, kind=WorkflowKind.IMPLEMENT)
plan = build_workflow("plan-with-pr", tmp_path, pr=19) plan = build_workflow("plan-with-pr", tmp_path, pr=19)
with closing(sqlite3.connect(repository.database_path)) as connection, connection: with closing(sqlite3.connect(engine_repository.database_path)) as connection, connection:
insert_workflow(connection, older, "2026-01-01T00:00:00+00:00") _sqlite.insert_workflow(connection, older, "2026-01-01T00:00:00+00:00")
insert_workflow(connection, newest, "2026-01-02T00:00:00+00:00") _sqlite.insert_workflow(connection, newest, "2026-01-02T00:00:00+00:00")
insert_workflow(connection, no_pr, "2026-01-03T00:00:00+00:00") _sqlite.insert_workflow(connection, no_pr, "2026-01-03T00:00:00+00:00")
insert_workflow(connection, plan, "2026-01-04T00:00:00+00:00") _sqlite.insert_workflow(connection, plan, "2026-01-04T00:00:00+00:00")
assert await repository.implementation_workflows("alice", "repo", 3) == [newest, older] assert await engine_repository.implementation_workflows("alice", "repo", 3) == [newest, older]
+49 -44
View File
@@ -1,7 +1,9 @@
import asyncio import asyncio
import sqlite3 import sqlite3
import threading
from collections.abc import Callable, Coroutine
from contextlib import closing from contextlib import closing
from pathlib import Path from typing import Any
import pytest import pytest
@@ -9,14 +11,17 @@ from agentci.engine.events import PermissionDenied, PermissionGranted
from agentci.engine.model import IncomingCommand, QueueName, TaskKind from agentci.engine.model import IncomingCommand, QueueName, TaskKind
from agentci.engine.repository import Repository from agentci.engine.repository import Repository
MIGRATIONS = Path(__file__).parents[1] / "src" / "agentci" / "migrations"
async def run_concurrently[T](
*factories: Callable[[], Coroutine[Any, Any, T]],
) -> list[T]:
barrier = threading.Barrier(len(factories))
@pytest.fixture def run(factory: Callable[[], Coroutine[Any, Any, T]]) -> T:
async def repository(tmp_path: Path) -> Repository: barrier.wait()
value = Repository(tmp_path / "state.sqlite3", MIGRATIONS) return asyncio.run(factory())
await value.initialize()
return value return list(await asyncio.gather(*(asyncio.to_thread(run, factory) for factory in factories)))
def command(delivery: str, *, issue: int = 3) -> IncomingCommand: def command(delivery: str, *, issue: int = 3) -> IncomingCommand:
@@ -33,91 +38,91 @@ def command(delivery: str, *, issue: int = 3) -> IncomingCommand:
async def test_concurrent_duplicate_accepts_create_one_job_and_event( async def test_concurrent_duplicate_accepts_create_one_job_and_event(
repository: Repository, engine_repository: Repository,
) -> None: ) -> None:
results = await asyncio.gather( results = await run_concurrently(
repository.accept(command("delivery-1")), lambda: engine_repository.accept(command("delivery-1")),
repository.accept(command("delivery-1")), lambda: engine_repository.accept(command("delivery-1")),
) )
assert sorted(result.duplicate for result in results) == [False, True] assert sorted(result.duplicate for result in results) == [False, True]
assert len({result.job.id for result in results}) == 1 assert len({result.job.id for result in results}) == 1
with closing(sqlite3.connect(repository.database_path)) as connection, connection: with closing(sqlite3.connect(engine_repository.database_path)) as connection, connection:
job_count = connection.execute("SELECT COUNT(*) FROM jobs").fetchone() job_count = connection.execute("SELECT COUNT(*) FROM jobs").fetchone()
event_count = connection.execute("SELECT COUNT(*) FROM job_events").fetchone() event_count = connection.execute("SELECT COUNT(*) FROM job_events").fetchone()
assert job_count == (1,) assert job_count == (1,)
assert event_count == (1,) assert event_count == (1,)
second = await repository.accept(command("delivery-2")) second = await engine_repository.accept(command("delivery-2"))
assert second.job.receive_sequence == results[0].job.receive_sequence + 1 assert second.job.receive_sequence == results[0].job.receive_sequence + 1
async def test_received_job_blocks_later_execute_task_for_same_target( async def test_received_job_blocks_later_execute_task_for_same_target(
repository: Repository, engine_repository: Repository,
) -> None: ) -> None:
first = (await repository.accept(command("delivery-1"))).job first = (await engine_repository.accept(command("delivery-1"))).job
second = (await repository.accept(command("delivery-2"))).job second = (await engine_repository.accept(command("delivery-2"))).job
await repository.apply("grant-2", PermissionGranted(job_id=second.id)) await engine_repository.apply("grant-2", PermissionGranted(job_id=second.id))
assert await repository.claim_task(QueueName.JOBS) is None assert await engine_repository.claim_task(QueueName.JOBS) is None
await repository.apply("deny-1", PermissionDenied(job_id=first.id)) await engine_repository.apply("deny-1", PermissionDenied(job_id=first.id))
task = await repository.claim_task(QueueName.JOBS) task = await engine_repository.claim_task(QueueName.JOBS)
assert task is not None assert task is not None
assert task.job_id == second.id assert task.job_id == second.id
assert task.kind is TaskKind.EXECUTE assert task.kind is TaskKind.EXECUTE
async def test_received_job_does_not_block_a_different_target( async def test_received_job_does_not_block_a_different_target(
repository: Repository, engine_repository: Repository,
) -> None: ) -> None:
await repository.accept(command("delivery-1", issue=3)) await engine_repository.accept(command("delivery-1", issue=3))
second = (await repository.accept(command("delivery-2", issue=4))).job second = (await engine_repository.accept(command("delivery-2", issue=4))).job
await repository.apply("grant-2", PermissionGranted(job_id=second.id)) await engine_repository.apply("grant-2", PermissionGranted(job_id=second.id))
task = await repository.claim_task(QueueName.JOBS) task = await engine_repository.claim_task(QueueName.JOBS)
assert task is not None assert task is not None
assert task.job_id == second.id assert task.job_id == second.id
async def test_claims_multiple_eligible_targets_without_duplicates( async def test_claims_multiple_eligible_targets_without_duplicates(
repository: Repository, engine_repository: Repository,
) -> None: ) -> None:
first = (await repository.accept(command("delivery-1", issue=3))).job first = (await engine_repository.accept(command("delivery-1", issue=3))).job
second = (await repository.accept(command("delivery-2", issue=4))).job second = (await engine_repository.accept(command("delivery-2", issue=4))).job
await repository.apply("grant-1", PermissionGranted(job_id=first.id)) await engine_repository.apply("grant-2", PermissionGranted(job_id=second.id))
await repository.apply("grant-2", PermissionGranted(job_id=second.id)) await engine_repository.apply("grant-1", PermissionGranted(job_id=first.id))
claimed = [ claimed = [
await repository.claim_task(QueueName.JOBS), await engine_repository.claim_task(QueueName.JOBS),
await repository.claim_task(QueueName.JOBS), await engine_repository.claim_task(QueueName.JOBS),
] ]
assert [task.job_id for task in claimed if task is not None] == [first.id, second.id] 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 assert await engine_repository.claim_task(QueueName.JOBS) is None
async def test_concurrent_claims_do_not_duplicate_task(repository: Repository) -> None: async def test_concurrent_claims_do_not_duplicate_task(engine_repository: Repository) -> None:
await repository.accept(command("delivery-1")) await engine_repository.accept(command("delivery-1"))
claims = await asyncio.gather( claims = await run_concurrently(
repository.claim_task(QueueName.CONTROL), lambda: engine_repository.claim_task(QueueName.CONTROL),
repository.claim_task(QueueName.CONTROL), lambda: engine_repository.claim_task(QueueName.CONTROL),
) )
claimed = [task for task in claims if task is not None] claimed = [task for task in claims if task is not None]
assert len(claimed) == 1 assert len(claimed) == 1
assert claimed[0].kind is TaskKind.AUTHORIZE assert claimed[0].kind is TaskKind.AUTHORIZE
assert claimed[0].queue is QueueName.CONTROL assert claimed[0].queue is QueueName.CONTROL
assert await repository.claim_task(QueueName.CONTROL) is None assert await engine_repository.claim_task(QueueName.CONTROL) is None
async def test_duplicate_event_id_cannot_be_reused_for_another_job( async def test_duplicate_event_id_cannot_be_reused_for_another_job(
repository: Repository, engine_repository: Repository,
) -> None: ) -> None:
first = (await repository.accept(command("delivery-1", issue=3))).job first = (await engine_repository.accept(command("delivery-1", issue=3))).job
second = (await repository.accept(command("delivery-2", issue=4))).job second = (await engine_repository.accept(command("delivery-2", issue=4))).job
await repository.apply("permission", PermissionGranted(job_id=first.id)) await engine_repository.apply("permission", PermissionGranted(job_id=first.id))
with pytest.raises(RuntimeError): with pytest.raises(RuntimeError):
await repository.apply("permission", PermissionGranted(job_id=second.id)) await engine_repository.apply("permission", PermissionGranted(job_id=second.id))
+19 -17
View File
@@ -142,9 +142,11 @@ async def migrated_pre_v3_repository(tmp_path: Path) -> Repository:
return repository return repository
async def test_schema_v2_migration_reconstructs_identity_order_and_iterate_commands( async def test_schema_v2_migration_preserves_jobs_and_reconstructs_state_machine_identity(
migrated_pre_v3_repository: Repository, migrated_pre_v3_repository: Repository,
) -> None: ) -> 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 ( with (
closing(sqlite3.connect(migrated_pre_v3_repository.database_path)) as connection, closing(sqlite3.connect(migrated_pre_v3_repository.database_path)) as connection,
connection, connection,
@@ -153,6 +155,10 @@ async def test_schema_v2_migration_reconstructs_identity_order_and_iterate_comma
"SELECT id, delivery_id, receive_sequence, command_body FROM jobs " "SELECT id, delivery_id, receive_sequence, command_body FROM jobs "
"ORDER BY receive_sequence" "ORDER BY receive_sequence"
).fetchall() ).fetchall()
storage_fields = connection.execute(
"""SELECT id, started_comment_id, created_at, started_at, finished_at
FROM jobs ORDER BY receive_sequence"""
).fetchall()
assert migrated == [ assert migrated == [
( (
@@ -164,21 +170,6 @@ async def test_schema_v2_migration_reconstructs_identity_order_and_iterate_comma
("pre-v3-job-b-queued", "delivery-queued", 2, "/agent iterate refine plan"), ("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 ( assert (
terminal terminal
and ( and (
@@ -386,7 +377,18 @@ def durable_snapshot(database_path: Path) -> tuple[object, ...]:
return ( return (
connection.execute("SELECT version FROM schema_migrations ORDER BY version").fetchall(), connection.execute("SELECT version FROM schema_migrations ORDER BY version").fetchall(),
connection.execute( connection.execute(
"SELECT id, delivery_id, receive_sequence, command_body FROM jobs ORDER BY id" """SELECT id, kind, target_key, repo_owner, repo_name, issue_number, pr_number,
requester, message, comment_id, delivery_id, receive_sequence,
command_body, workflow_id, status, stage, error, runtime_session_id,
accepted_comment_id, started_comment_id, comment_body, created_at,
started_at, finished_at
FROM jobs ORDER BY id"""
).fetchall(),
connection.execute(
"""SELECT 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
FROM workflows ORDER BY id"""
).fetchall(), ).fetchall(),
connection.execute( connection.execute(
"""SELECT event_id, job_id, event_type, payload_json """SELECT event_id, job_id, event_type, payload_json
+39 -54
View File
@@ -1,24 +1,14 @@
import sqlite3 import sqlite3
from contextlib import closing from contextlib import closing
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from pathlib import Path
import pytest import pytest
from agentci.engine import _sqlite
from agentci.engine import repository as repository_module from agentci.engine import repository as repository_module
from agentci.engine.events import JobStarted, PermissionGranted from agentci.engine.events import JobStarted, PermissionGranted
from agentci.engine.model import IncomingCommand, QueueName, Task, TaskKind from agentci.engine.model import IncomingCommand, QueueName, Task, TaskKind
from agentci.engine.repository import Repository from agentci.engine.repository import Repository
from tests.conftest import SQLiteClock
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: def command(delivery: str, *, issue: int = 3) -> IncomingCommand:
@@ -34,8 +24,8 @@ def command(delivery: str, *, issue: int = 3) -> IncomingCommand:
) )
def task_storage(repository: Repository, task_id: int) -> tuple[object, ...]: def task_storage(engine_repository: Repository, task_id: int) -> tuple[object, ...]:
with closing(sqlite3.connect(repository.database_path)) as connection, connection: with closing(sqlite3.connect(engine_repository.database_path)) as connection, connection:
row = connection.execute( row = connection.execute(
"""SELECT status, attempts, available_at, error, created_at, started_at, finished_at """SELECT status, attempts, available_at, error, created_at, started_at, finished_at
FROM listener_tasks WHERE id=?""", FROM listener_tasks WHERE id=?""",
@@ -46,20 +36,18 @@ def task_storage(repository: Repository, task_id: int) -> tuple[object, ...]:
async def test_claim_and_complete_record_attempt_and_lifecycle_timestamps( async def test_claim_and_complete_record_attempt_and_lifecycle_timestamps(
repository: Repository, engine_repository: Repository,
monkeypatch: pytest.MonkeyPatch, sqlite_clock: SQLiteClock,
) -> None: ) -> None:
clock = {"now": "2026-02-01T00:00:00+00:00"} job = (await engine_repository.accept(command("delivery-1"))).job
monkeypatch.setattr(_sqlite, "now", lambda: clock["now"]) sqlite_clock.now = "2026-02-01T00:01:00+00:00"
job = (await repository.accept(command("delivery-1"))).job
clock["now"] = "2026-02-01T00:01:00+00:00"
task = await repository.claim_task(QueueName.CONTROL) task = await engine_repository.claim_task(QueueName.CONTROL)
assert task is not None assert task is not None
clock["now"] = "2026-02-01T00:02:00+00:00" sqlite_clock.now = "2026-02-01T00:02:00+00:00"
await repository.complete_task(task.id) await engine_repository.complete_task(task.id)
assert (task, task_storage(repository, task.id)) == ( assert (task, task_storage(engine_repository, task.id)) == (
Task( Task(
id=task.id, id=task.id,
job_id=job.id, job_id=job.id,
@@ -82,17 +70,16 @@ async def test_claim_and_complete_record_attempt_and_lifecycle_timestamps(
@pytest.mark.parametrize(("attempts", "delay_seconds"), [(1, 2), (9, 256)]) @pytest.mark.parametrize(("attempts", "delay_seconds"), [(1, 2), (9, 256)])
async def test_retry_uses_bounded_backoff_and_preserves_attempt_history_until_due( async def test_retry_uses_bounded_backoff_and_preserves_attempt_history_until_due(
repository: Repository, engine_repository: Repository,
sqlite_clock: SQLiteClock,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
attempts: int, attempts: int,
delay_seconds: int, delay_seconds: int,
) -> None: ) -> None:
clock = {"now": "2026-02-01T00:00:00+00:00"} await engine_repository.accept(command("delivery-1"))
monkeypatch.setattr(_sqlite, "now", lambda: clock["now"]) task = await engine_repository.claim_task(QueueName.CONTROL)
await repository.accept(command("delivery-1"))
task = await repository.claim_task(QueueName.CONTROL)
assert task is not None assert task is not None
with closing(sqlite3.connect(repository.database_path)) as connection, connection: with closing(sqlite3.connect(engine_repository.database_path)) as connection, connection:
connection.execute("UPDATE listener_tasks SET attempts=? WHERE id=?", (attempts, task.id)) connection.execute("UPDATE listener_tasks SET attempts=? WHERE id=?", (attempts, task.id))
retry_time = datetime(2026, 2, 1, 1, tzinfo=UTC) retry_time = datetime(2026, 2, 1, 1, tzinfo=UTC)
@@ -105,12 +92,12 @@ async def test_retry_uses_bounded_backoff_and_preserves_attempt_history_until_du
monkeypatch.setattr(repository_module, "datetime", FixedDateTime) monkeypatch.setattr(repository_module, "datetime", FixedDateTime)
error = "failure: " + "x" * 1100 error = "failure: " + "x" * 1100
await repository.retry_task(task.id, attempts, error) await engine_repository.retry_task(task.id, attempts, error)
pending_storage = task_storage(repository, task.id) pending_storage = task_storage(engine_repository, task.id)
clock["now"] = (available_at - timedelta(microseconds=1)).isoformat() sqlite_clock.now = (available_at - timedelta(microseconds=1)).isoformat()
early_claim = await repository.claim_task(QueueName.CONTROL) early_claim = await engine_repository.claim_task(QueueName.CONTROL)
clock["now"] = available_at.isoformat() sqlite_clock.now = available_at.isoformat()
due_claim = await repository.claim_task(QueueName.CONTROL) due_claim = await engine_repository.claim_task(QueueName.CONTROL)
assert ( assert (
pending_storage, pending_storage,
@@ -132,35 +119,33 @@ async def test_retry_uses_bounded_backoff_and_preserves_attempt_history_until_du
async def test_recovery_requeues_control_and_unstarted_execution_but_fails_started_execution( async def test_recovery_requeues_control_and_unstarted_execution_but_fails_started_execution(
repository: Repository, engine_repository: Repository,
monkeypatch: pytest.MonkeyPatch, sqlite_clock: SQLiteClock,
) -> None: ) -> 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)) await engine_repository.accept(command("delivery-1", issue=1))
control_task = await repository.claim_task(QueueName.CONTROL) control_task = await engine_repository.claim_task(QueueName.CONTROL)
assert control_task is not None assert control_task is not None
queued_job = (await repository.accept(command("delivery-2", issue=2))).job queued_job = (await engine_repository.accept(command("delivery-2", issue=2))).job
await repository.apply("queued:grant", PermissionGranted(job_id=queued_job.id)) await engine_repository.apply("queued:grant", PermissionGranted(job_id=queued_job.id))
queued_execute = await repository.claim_task(QueueName.JOBS) queued_execute = await engine_repository.claim_task(QueueName.JOBS)
assert queued_execute is not None assert queued_execute is not None
running_job = (await repository.accept(command("delivery-3", issue=3))).job running_job = (await engine_repository.accept(command("delivery-3", issue=3))).job
await repository.apply("running:grant", PermissionGranted(job_id=running_job.id)) await engine_repository.apply("running:grant", PermissionGranted(job_id=running_job.id))
running_execute = await repository.claim_task(QueueName.JOBS) running_execute = await engine_repository.claim_task(QueueName.JOBS)
assert running_execute is not None assert running_execute is not None
await repository.apply("running:start", JobStarted(job_id=running_job.id)) await engine_repository.apply("running:start", JobStarted(job_id=running_job.id))
clock["now"] = "2026-02-01T01:00:00+00:00" sqlite_clock.now = "2026-02-01T01:00:00+00:00"
await repository.recover_tasks() await engine_repository.recover_tasks()
assert ( assert (
task_storage(repository, control_task.id), task_storage(engine_repository, control_task.id),
task_storage(repository, queued_execute.id), task_storage(engine_repository, queued_execute.id),
task_storage(repository, running_execute.id), task_storage(engine_repository, running_execute.id),
[job.id for job in await repository.running_jobs()], [job.id for job in await engine_repository.running_jobs()],
) == ( ) == (
( (
"pending", "pending",
+19 -25
View File
@@ -32,36 +32,30 @@ def runtime(opencode: object, gitea: object) -> Runtime:
) )
async def test_close_releases_provider_clients_in_order() -> None: @pytest.mark.parametrize(
events: list[str] = [] ("opencode_error", "gitea_error", "expected_error"),
value = runtime(ClosingClient("opencode", events), ClosingClient("gitea", events)) [
(None, None, None),
await value.close() (RuntimeError("opencode close failed"), None, "opencode close failed"),
(None, RuntimeError("gitea close failed"), "gitea close failed"),
assert events == ["opencode", "gitea"] ],
ids=["success", "opencode-failure", "gitea-failure"],
)
async def test_close_still_releases_gitea_when_opencode_close_fails() -> None: async def test_close_releases_provider_clients_in_order(
opencode_error: Exception | None,
gitea_error: Exception | None,
expected_error: str | None,
) -> None:
events: list[str] = [] events: list[str] = []
value = runtime( value = runtime(
ClosingClient("opencode", events, RuntimeError("opencode close failed")), ClosingClient("opencode", events, opencode_error),
ClosingClient("gitea", events), ClosingClient("gitea", events, gitea_error),
) )
with pytest.raises(RuntimeError, match="opencode close failed"): if expected_error is None:
await value.close() await value.close()
else:
assert events == ["opencode", "gitea"] with pytest.raises(RuntimeError, match=expected_error):
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() await value.close()
assert events == ["opencode", "gitea"] assert events == ["opencode", "gitea"]
+54 -38
View File
@@ -98,23 +98,38 @@ async def test_invalid_signature_precedes_parsing_and_event_filtering(event: str
assert repository.accepted == [] assert repository.accepted == []
async def test_signed_unsupported_event_is_ignored_without_parsing() -> None:
repository = FakeRepository()
response = await post_webhook(repository, b"not-json", event="push")
assert response.status_code == 204
assert repository.accepted == []
@pytest.mark.parametrize( @pytest.mark.parametrize(
("command", "requester"), ("body", "event"),
[("ordinary discussion", "alice"), ("/agent plan", "AgentCI")], [
pytest.param(
b"not-json",
"push",
id="unsupported-event-before-parsing",
),
pytest.param(
encoded(payload("ordinary discussion")),
"issue_comment",
id="non-command",
),
pytest.param(
encoded(payload("/agent plan", requester="AgentCI")),
"issue_comment",
id="bot-author",
),
pytest.param(
encoded({"action": "edited"}),
"issue_comment",
id="non-created-before-payload",
),
],
) )
async def test_non_command_and_bot_comment_are_ignored(command: str, requester: str) -> None: async def test_authenticated_irrelevant_webhooks_are_ignored(
body: bytes,
event: str,
) -> None:
repository = FakeRepository() repository = FakeRepository()
response = await post_webhook(repository, encoded(payload(command, requester=requester))) response = await post_webhook(repository, body, event=event)
assert response.status_code == 204 assert response.status_code == 204
assert repository.accepted == [] assert repository.accepted == []
@@ -130,40 +145,41 @@ async def test_duplicate_delivery_returns_ok() -> None:
@pytest.mark.parametrize( @pytest.mark.parametrize(
"body", ("body", "delivery", "detail"),
[ [
b"{", pytest.param(b"{", "delivery", "Invalid webhook payload", id="invalid-json"),
encoded([]), pytest.param(encoded([]), "delivery", "Invalid webhook payload", id="non-object"),
pytest.param(
encoded({"action": "created"}), encoded({"action": "created"}),
"delivery",
"Invalid webhook payload",
id="missing-comment",
),
pytest.param(
encoded({"action": "created", "comment": {}}), encoded({"action": "created", "comment": {}}),
"delivery",
"Invalid webhook payload",
id="incomplete-comment",
),
pytest.param(
encoded(payload("/agent plan")),
None,
"Missing X-Gitea-Delivery",
id="missing-delivery",
),
], ],
) )
async def test_malformed_json_and_payload_contract_return_bad_request(body: bytes) -> None: async def test_authenticated_bad_requests_are_rejected_without_persistence(
body: bytes,
delivery: str | None,
detail: str,
) -> None:
repository = FakeRepository() repository = FakeRepository()
response = await post_webhook(repository, body) response = await post_webhook(repository, body, delivery=delivery)
assert response.status_code == 400 assert response.status_code == 400
assert response.json() == {"detail": "Invalid webhook payload"} assert response.json() == {"detail": detail}
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 == [] assert repository.accepted == []
+55 -89
View File
@@ -20,7 +20,6 @@ from agentci.engine.events import (
JobRejected as RejectedEvent, JobRejected as RejectedEvent,
) )
from agentci.engine.model import ( from agentci.engine.model import (
IncomingCommand,
Job, Job,
JobKind, JobKind,
JobStatus, JobStatus,
@@ -31,12 +30,9 @@ from agentci.engine.model import (
WorkflowKind, WorkflowKind,
) )
from agentci.engine.reducer import render_job_comment from agentci.engine.reducer import render_job_comment
from agentci.engine.repository import Repository
from agentci.integrations.gitea.models import CommentInfo from agentci.integrations.gitea.models import CommentInfo
from agentci.workflows.render import JobRejected from agentci.workflows.render import JobRejected
MIGRATIONS = Path(__file__).parents[1] / "src" / "agentci" / "migrations"
class FakeRepository: class FakeRepository:
def __init__( def __init__(
@@ -54,7 +50,6 @@ class FakeRepository:
self.events: list[tuple[str, object]] = [] self.events: list[tuple[str, object]] = []
self.operations: list[str] = [] self.operations: list[str] = []
self.running: list[Job] = [] self.running: list[Job] = []
self.failed_workflows: list[str] = []
self.complete_stop: asyncio.Event | None = None self.complete_stop: asyncio.Event | None = None
async def claim_task(self, queue: QueueName) -> Task | None: async def claim_task(self, queue: QueueName) -> Task | None:
@@ -91,9 +86,6 @@ class FakeRepository:
async def get_workflow(self, _workflow_id: str) -> Workflow | None: async def get_workflow(self, _workflow_id: str) -> Workflow | None:
return self.workflow return self.workflow
async def fail_job_workflow(self, job_id: str) -> None:
self.failed_workflows.append(job_id)
class FakeGitea: class FakeGitea:
def __init__(self, *, permitted: bool = True) -> None: def __init__(self, *, permitted: bool = True) -> None:
@@ -181,7 +173,7 @@ def task(
def make_worker( def make_worker(
tmp_path: Path, tmp_path: Path,
repository: FakeRepository | Repository, repository: FakeRepository,
*, *,
gitea: FakeGitea | None = None, gitea: FakeGitea | None = None,
opencode: FakeOpenCode | None = None, opencode: FakeOpenCode | None = None,
@@ -329,74 +321,47 @@ async def test_run_recovers_before_starting_configured_consumers(
@pytest.mark.parametrize( @pytest.mark.parametrize(
("permitted", "event_type", "suffix"), ("status", "permitted", "expected_events", "expected_permission_calls"),
[ [
(True, PermissionGranted, "permission-granted"), pytest.param(
(False, PermissionDenied, "permission-denied"), JobStatus.RECEIVED,
True,
[("task:7:permission-granted", PermissionGranted(job_id="job"))],
[("org", "repo", "alice")],
id="received-permitted",
),
pytest.param(
JobStatus.RECEIVED,
False,
[("task:7:permission-denied", PermissionDenied(job_id="job"))],
[("org", "repo", "alice")],
id="received-denied",
),
pytest.param(
JobStatus.QUEUED,
True,
[],
[],
id="queued",
),
], ],
) )
async def test_authorize_records_permission_outcome( async def test_handle_authorization_obeys_job_state_and_permission(
tmp_path: Path, tmp_path: Path,
status: JobStatus,
permitted: bool, permitted: bool,
event_type: type[PermissionGranted] | type[PermissionDenied], expected_events: list[tuple[str, object]],
suffix: str, expected_permission_calls: list[tuple[str, str, str]],
) -> None: ) -> None:
current = job() repository = FakeRepository(job(status=status))
repository = FakeRepository(current)
gitea = FakeGitea(permitted=permitted) gitea = FakeGitea(permitted=permitted)
authorize = task(TaskKind.AUTHORIZE, QueueName.CONTROL)
await make_worker(tmp_path, repository, gitea=gitea)._authorize(authorize, current) await make_worker(tmp_path, repository, gitea=gitea)._handle(
task(TaskKind.AUTHORIZE, QueueName.CONTROL)
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 == expected_events
assert repository.events == [] assert gitea.permission_calls == expected_permission_calls
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( async def test_execute_completes_with_workflow_comment(
@@ -603,20 +568,23 @@ async def test_abort_collects_and_deduplicates_workflow_sessions(
assert set(opencode.aborted) == {(session, workspace) for session in expected} assert set(opencode.aborted) == {(session, workspace) for session in expected}
async def test_abort_uses_one_shot_workspace_without_workflow(tmp_path: Path) -> None: @pytest.mark.parametrize(
opencode = FakeOpenCode() ("workflow_id", "session_id", "has_workflow", "uses_one_shot"),
[
await make_worker(tmp_path, FakeRepository(), opencode=opencode)._abort_job_sessions( pytest.param("missing", "session", False, True, id="one-shot-fallback"),
job(workflow_id="missing", session_id="session") pytest.param("flow", "one-shot", True, False, id="workflow-precedence"),
pytest.param(None, None, False, False, id="no-sessions"),
],
) )
async def test_abort_session_source_precedence(
assert opencode.aborted == [("session", tmp_path / "fix-job" / "repo")]
async def test_abort_does_not_mix_one_shot_session_into_existing_workflow(
tmp_path: Path, tmp_path: Path,
workflow_id: str | None,
session_id: str | None,
has_workflow: bool,
uses_one_shot: bool,
) -> None: ) -> None:
workflow = Workflow( persisted = (
Workflow(
id="flow", id="flow",
kind=WorkflowKind.IMPLEMENT, kind=WorkflowKind.IMPLEMENT,
repo_owner="org", repo_owner="org",
@@ -625,21 +593,19 @@ async def test_abort_does_not_mix_one_shot_session_into_existing_workflow(
workspace_path=tmp_path / "workflow" / "repo", workspace_path=tmp_path / "workflow" / "repo",
base_sha="base", base_sha="base",
) )
if has_workflow
else None
)
opencode = FakeOpenCode() opencode = FakeOpenCode()
await make_worker( await make_worker(
tmp_path, FakeRepository(workflow=workflow), opencode=opencode tmp_path,
)._abort_job_sessions(job(workflow_id="flow", session_id="one-shot")) FakeRepository(workflow=persisted),
opencode=opencode,
)._abort_job_sessions(job(workflow_id=workflow_id, session_id=session_id))
assert opencode.aborted == [] expected = [("session", tmp_path / "fix-job" / "repo")] if uses_one_shot else []
assert opencode.aborted == expected
async def test_abort_without_persisted_sessions_is_noop(tmp_path: Path) -> None:
opencode = FakeOpenCode()
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: def test_safe_error_is_single_line_and_bounded() -> None:
+1 -20
View File
@@ -1,5 +1,3 @@
from dataclasses import replace
from enum import StrEnum
from typing import cast from typing import cast
import pytest import pytest
@@ -43,6 +41,7 @@ async def test_dispatch_routes_every_job_kind_and_returns_body(
kind: JobKind, kind: JobKind,
expected_route: str, expected_route: str,
) -> None: ) -> None:
assert {route_kind for route_kind, _ in ROUTES} == set(JobKind)
calls: list[tuple[str, Job]] = [] calls: list[tuple[str, Job]] = []
def route(name: str): def route(name: str):
@@ -73,21 +72,3 @@ async def test_dispatch_rejects_unparsed_command() -> None:
cast(JobRun, object()), cast(JobRun, object()),
cast(WorkflowServices, 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,)
+54 -154
View File
@@ -1,33 +1,15 @@
import json import json
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any, cast
import pytest import pytest
from agentci.engine.model import Job, JobKind, Workflow, WorkflowKind, WorkflowStatus from agentci.engine.model import Job, JobKind, Workflow, WorkflowKind, WorkflowStatus
from agentci.engine.run import JobRun
from agentci.integrations.gitea.models import CommentInfo, IssueInfo, PullRequestInfo from agentci.integrations.gitea.models import CommentInfo, IssueInfo, PullRequestInfo
from agentci.workflows.implementation import implement from agentci.workflows.implementation import implement
from agentci.workflows.model import AgentResult, ReviewReport from agentci.workflows.model import AgentResult, ReviewReport
from agentci.workflows.render import JobRejected from agentci.workflows.render import JobRejected
from agentci.workflows.services import WorkflowServices from tests.workflow_support import make_workflow_harness
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: class FakeRepository:
@@ -85,71 +67,6 @@ class FakeGitea:
return pull(number=42, branch=values["head"]) 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: def job() -> Job:
return Job( return Job(
id="job-1", id="job-1",
@@ -203,95 +120,63 @@ def pull(
) )
def make_services( def implementation_settings(tmp_path: Path) -> SimpleNamespace:
tmp_path: Path, return SimpleNamespace(
*,
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", branch_prefix="agent",
workspaces_dir=tmp_path / "workspaces", workspaces_dir=tmp_path / "workspaces",
gitea_url="https://git.example.test", gitea_url="https://git.example.test",
implement_model="provider/model", implement_model="provider/model",
implement_variant="high", implement_variant="high",
implement_review_rounds=2, 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( async def test_initial_implementation_completes_review_commit_push_and_pr(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
stale = workflow(pr_number=8) stale = workflow(pr_number=8)
services, repository, gitea, git, development, prompts, opencode = make_services( repository = FakeRepository(implementations=[stale])
tmp_path, gitea = FakeGitea(existing_pulls={8: pull(state="closed")})
implementations=[stale], harness = make_workflow_harness(
existing_pulls={8: pull(state="closed")}, settings=implementation_settings(tmp_path),
repository=repository,
gitea=gitea,
responses=[
AgentResult(
summary_markdown="# Implement widget\n\nHandled empty input.",
tests=["pytest: passed"],
),
ReviewReport(summary="Ready", findings=[]),
],
) )
run = RecordingRun()
body = await implement(job(), run, services) body = await implement(job(), harness.run, harness.services)
assert len(run.created_workflows) == 1 assert len(harness.run.created_workflows) == 1
created, created_stage = run.created_workflows[0] created, created_stage = harness.run.created_workflows[0]
assert created_stage == "installing development environment" assert created_stage == "installing development environment"
assert created.status is WorkflowStatus.ACTIVE assert created.status is WorkflowStatus.ACTIVE
assert created.branch == f"agent/issue-7-{created.id[:8]}" assert created.branch == f"agent/issue-7-{created.id[:8]}"
assert created.workspace_path == tmp_path / "workspaces" / created.id / "repo" assert created.workspace_path == tmp_path / "workspaces" / created.id / "repo"
assert created.base_sha == "base-sha" assert created.base_sha == "base-sha"
assert development.workspaces == [created.workspace_path] assert harness.development.workspaces == [created.workspace_path]
assert run.linked_sessions == ["implementation-session"] assert harness.run.linked_sessions == ["implementation-session"]
assert opencode.created_sessions == [ assert harness.opencode.created_sessions == [
(created.workspace_path, "implementation"), (created.workspace_path, "implementation"),
(created.workspace_path, "implementation-review"), (created.workspace_path, "implementation-review"),
] ]
assert [call["result_type"] for call in opencode.resume_calls] == [ assert [call["result_type"] for call in harness.opencode.resume_calls] == [
AgentResult, AgentResult,
ReviewReport, ReviewReport,
] ]
initial_prompt = prompts.calls[0] initial_prompt = harness.prompts.calls[0]
assert initial_prompt[0] == "implement_initial" assert initial_prompt[0] == "implement_initial"
assert initial_prompt[1]["artifact"] == "(no canonical plan)" assert initial_prompt[1]["artifact"] == "(no canonical plan)"
assert initial_prompt[1]["request"] == "Keep the change focused." assert initial_prompt[1]["request"] == "Keep the change focused."
assert "Please cover empty input." in initial_prompt[1]["context"] assert initial_prompt[1]["context"].startswith("Repository: org/repo\n")
assert "Job queued" not in initial_prompt[1]["context"]
assert git.calls == [ assert harness.git.calls == [
("clone", "org", "repo", "main", created.workspace_path), ("clone", "org", "repo", "main", created.workspace_path),
("create_branch", created.workspace_path, created.branch), ("create_branch", created.workspace_path, created.branch),
("has_changes", created.workspace_path), ("has_changes", created.workspace_path),
@@ -338,16 +223,29 @@ async def test_initial_implementation_completes_review_commit_push_and_pr(
async def test_initial_implementation_rejects_clean_worktree_before_commit_or_pr( async def test_initial_implementation_rejects_clean_worktree_before_commit_or_pr(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
services, repository, gitea, git, _, _, _ = make_services(tmp_path, changed=False) repository = FakeRepository()
run = RecordingRun() gitea = FakeGitea()
harness = make_workflow_harness(
settings=implementation_settings(tmp_path),
repository=repository,
gitea=gitea,
changed=False,
responses=[
AgentResult(
summary_markdown="# Implement widget\n\nHandled empty input.",
tests=["pytest: passed"],
),
ReviewReport(summary="Ready", findings=[]),
],
)
with pytest.raises( with pytest.raises(
JobRejected, JobRejected,
match="OpenCode completed without producing any file changes", match="OpenCode completed without producing any file changes",
): ):
await implement(job(), run, services) await implement(job(), harness.run, harness.services)
assert [call[0] for call in git.calls] == [ assert [call[0] for call in harness.git.calls] == [
"clone", "clone",
"create_branch", "create_branch",
"has_changes", "has_changes",
@@ -355,7 +253,7 @@ async def test_initial_implementation_rejects_clean_worktree_before_commit_or_pr
assert gitea.created_pulls == [] assert gitea.created_pulls == []
assert repository.saved_workflows assert repository.saved_workflows
assert all(item.status is WorkflowStatus.ACTIVE for item in repository.saved_workflows) assert all(item.status is WorkflowStatus.ACTIVE for item in repository.saved_workflows)
assert "creating pull request" not in run.stages assert "creating pull request" not in harness.run.stages
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -376,17 +274,19 @@ async def test_initial_implementation_rejects_duplicate_agent_pr_before_clone(
existing_pull: PullRequestInfo, existing_pull: PullRequestInfo,
message: str, message: str,
) -> None: ) -> None:
services, _, gitea, git, development, _, opencode = make_services( repository = FakeRepository(implementations=[workflow(pr_number=8)])
tmp_path, gitea = FakeGitea(existing_pulls={8: existing_pull})
implementations=[workflow(pr_number=8)], harness = make_workflow_harness(
existing_pulls={8: existing_pull}, settings=implementation_settings(tmp_path),
repository=repository,
gitea=gitea,
) )
with pytest.raises(JobRejected) as error: with pytest.raises(JobRejected) as error:
await implement(job(), RecordingRun(), services) await implement(job(), harness.run, harness.services)
assert str(error.value) == message assert str(error.value) == message
assert git.calls == [] assert harness.git.calls == []
assert development.workspaces == [] assert harness.development.workspaces == []
assert opencode.resume_calls == [] assert harness.opencode.resume_calls == []
assert gitea.default_branch_calls == [] assert gitea.default_branch_calls == []
+70 -135
View File
@@ -1,38 +1,15 @@
import json import json
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any, cast
import pytest import pytest
from pydantic import BaseModel
from agentci.engine.model import Job, JobKind, Workflow, WorkflowKind, WorkflowStatus from agentci.engine.model import Job, JobKind, Workflow, WorkflowKind, WorkflowStatus
from agentci.engine.run import JobRun
from agentci.integrations.gitea.models import CommentInfo, IssueInfo, PullRequestInfo from agentci.integrations.gitea.models import CommentInfo, IssueInfo, PullRequestInfo
from agentci.workflows.model import DiscussionReply, PlanArtifact, ReviewReport from agentci.workflows.model import DiscussionReply, PlanArtifact, ReviewReport
from agentci.workflows.plan import create_plan, discuss_plan, iterate_plan from agentci.workflows.plan import create_plan, discuss_plan, iterate_plan
from agentci.workflows.render import JobRejected from agentci.workflows.render import JobRejected
from agentci.workflows.services import WorkflowServices from tests.workflow_support import make_workflow_harness
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: class FakeRepository:
@@ -86,41 +63,6 @@ class FakeGitea:
return self.pulls[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: def job(kind: JobKind, *, message: str | None = "Please be specific.") -> Job:
return Job( return Job(
id="job-plan", id="job-plan",
@@ -192,78 +134,50 @@ def pull(*, state: str = "open", merged: bool = False) -> PullRequestInfo:
) )
def make_services( def plan_settings(tmp_path: Path) -> SimpleNamespace:
tmp_path: Path, return SimpleNamespace(
*,
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", workspaces_dir=tmp_path / "workspaces",
plan_model="provider/model", plan_model="provider/model",
plan_variant="high", plan_variant="high",
plan_review_rounds=3, 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( async def test_create_plan_completes_primary_and_independent_review(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
services, repository, gitea, git, prompts, opencode = make_services( repository = FakeRepository()
tmp_path, gitea = FakeGitea()
harness = make_workflow_harness(
settings=plan_settings(tmp_path),
repository=repository,
gitea=gitea,
responses=[ responses=[
PlanArtifact(plan_markdown="# Complete plan"), PlanArtifact(plan_markdown="# Complete plan"),
ReviewReport(summary="Ready", findings=[]), ReviewReport(summary="Ready", findings=[]),
], ],
) )
run = RecordingRun()
body = await create_plan(job(JobKind.PLAN), run, services) body = await create_plan(job(JobKind.PLAN), harness.run, harness.services)
created, stage = run.created_workflows[0] created, stage = harness.run.created_workflows[0]
assert stage == "planning" assert stage == "planning"
assert created.kind is WorkflowKind.PLAN assert created.kind is WorkflowKind.PLAN
assert created.workspace_path == tmp_path / "workspaces" / created.id / "repo" assert created.workspace_path == tmp_path / "workspaces" / created.id / "repo"
assert git.clone_calls == [("org", "repo", "trunk", created.workspace_path)] assert harness.git.calls == [("clone", "org", "repo", "trunk", created.workspace_path)]
assert gitea.default_branch_calls == [("org", "repo")] assert gitea.default_branch_calls == [("org", "repo")]
assert run.linked_sessions == ["plan-session"] assert harness.run.linked_sessions == ["plan-session"]
assert opencode.created_sessions == [ assert harness.opencode.created_sessions == [
(created.workspace_path, "plan"), (created.workspace_path, "plan"),
(created.workspace_path, "plan-review"), (created.workspace_path, "plan-review"),
] ]
assert [call["result_type"] for call in opencode.resume_calls] == [ assert [call["result_type"] for call in harness.opencode.resume_calls] == [
PlanArtifact, PlanArtifact,
ReviewReport, ReviewReport,
] ]
assert prompts.calls[0][0] == "plan_initial" assert harness.prompts.calls[0][0] == "plan_initial"
assert prompts.calls[0][1]["request"] == "Please be specific." assert harness.prompts.calls[0][1]["request"] == "Please be specific."
assert "Use the existing API." in prompts.calls[0][1]["context"] assert harness.prompts.calls[0][1]["context"].startswith("Repository: org/repo\n")
assert "Job queued" not in prompts.calls[0][1]["context"]
completed = repository.saved_workflows[-1] completed = repository.saved_workflows[-1]
assert completed.status is WorkflowStatus.COMPLETED assert completed.status is WorkflowStatus.COMPLETED
@@ -281,20 +195,21 @@ async def test_discuss_plan_resumes_primary_session_without_replacing_artifact(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
existing = plan_workflow() existing = plan_workflow()
services, repository, _, _, prompts, opencode = make_services( repository = FakeRepository(latest=existing)
tmp_path, harness = make_workflow_harness(
latest=existing, settings=plan_settings(tmp_path),
repository=repository,
gitea=FakeGitea(),
responses=[DiscussionReply(markdown="The API remains compatible.")], responses=[DiscussionReply(markdown="The API remains compatible.")],
) )
run = RecordingRun()
body = await discuss_plan(job(JobKind.DISCUSS), run, services) body = await discuss_plan(job(JobKind.DISCUSS), harness.run, harness.services)
assert run.linked_workflows == [(existing.id, "discussing")] assert harness.run.linked_workflows == [(existing.id, "discussing")]
assert opencode.created_sessions == [] assert harness.opencode.created_sessions == []
assert opencode.resume_calls[0]["session_id"] == "primary-session" assert harness.opencode.resume_calls[0]["session_id"] == "primary-session"
assert opencode.resume_calls[0]["schema_name"] == "discussion.json" assert harness.opencode.resume_calls[0]["schema_name"] == "discussion.json"
assert prompts.calls == [ assert harness.prompts.calls == [
( (
"discuss", "discuss",
{"artifact": "Original plan", "message": "Please be specific."}, {"artifact": "Original plan", "message": "Please be specific."},
@@ -327,45 +242,57 @@ async def test_discuss_plan_rejects_missing_or_incompatible_plan(
latest: Workflow | None, latest: Workflow | None,
message: str, message: str,
) -> None: ) -> None:
services, _, _, _, _, opencode = make_services(tmp_path, latest=latest) repository = FakeRepository(latest=latest)
harness = make_workflow_harness(
settings=plan_settings(tmp_path),
repository=repository,
gitea=FakeGitea(),
)
with pytest.raises(JobRejected) as error: with pytest.raises(JobRejected) as error:
await discuss_plan(job(JobKind.DISCUSS), RecordingRun(), services) await discuss_plan(job(JobKind.DISCUSS), harness.run, harness.services)
assert str(error.value) == message assert str(error.value) == message
assert opencode.resume_calls == [] assert harness.opencode.resume_calls == []
async def test_iterate_plan_revises_then_reuses_reviewer_session( async def test_iterate_plan_revises_then_reuses_reviewer_session(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
existing = plan_workflow() existing = plan_workflow()
services, repository, gitea, _, prompts, opencode = make_services( repository = FakeRepository(
tmp_path,
latest=existing, latest=existing,
implementations=[implementation_workflow(), implementation_workflow(None)], implementations=[implementation_workflow(), implementation_workflow(None)],
pulls={9: pull(state="closed")}, )
gitea = FakeGitea({9: pull(state="closed")})
harness = make_workflow_harness(
settings=plan_settings(tmp_path),
repository=repository,
gitea=gitea,
responses=[ responses=[
PlanArtifact(plan_markdown="# Revised plan"), PlanArtifact(plan_markdown="# Revised plan"),
ReviewReport(summary="Ready", findings=[]), ReviewReport(summary="Ready", findings=[]),
], ],
) )
run = RecordingRun()
body = await iterate_plan(job(JobKind.ITERATE_PLAN, message=None), run, services) body = await iterate_plan(
job(JobKind.ITERATE_PLAN, message=None),
harness.run,
harness.services,
)
assert gitea.pull_calls == [9] assert gitea.pull_calls == [9]
assert run.linked_workflows == [(existing.id, "iterating plan")] assert harness.run.linked_workflows == [(existing.id, "iterating plan")]
assert opencode.created_sessions == [] assert harness.opencode.created_sessions == []
assert [call["session_id"] for call in opencode.resume_calls] == [ assert [call["session_id"] for call in harness.opencode.resume_calls] == [
"primary-session", "primary-session",
"reviewer-session", "reviewer-session",
] ]
assert [call["result_type"] for call in opencode.resume_calls] == [ assert [call["result_type"] for call in harness.opencode.resume_calls] == [
PlanArtifact, PlanArtifact,
ReviewReport, ReviewReport,
] ]
iterate_prompt = prompts.calls[0] iterate_prompt = harness.prompts.calls[0]
assert iterate_prompt[0] == "plan_iterate" assert iterate_prompt[0] == "plan_iterate"
assert iterate_prompt[1]["artifact"] == "Original plan" assert iterate_prompt[1]["artifact"] == "Original plan"
assert iterate_prompt[1]["message"] == ("(refine using the latest discussion and prior review)") assert iterate_prompt[1]["message"] == ("(refine using the latest discussion and prior review)")
@@ -403,14 +330,19 @@ async def test_iterate_plan_rejects_missing_or_incompatible_plan(
latest: Workflow | None, latest: Workflow | None,
message: str, message: str,
) -> None: ) -> None:
services, repository, _, _, _, opencode = make_services(tmp_path, latest=latest) repository = FakeRepository(latest=latest)
harness = make_workflow_harness(
settings=plan_settings(tmp_path),
repository=repository,
gitea=FakeGitea(),
)
with pytest.raises(JobRejected) as error: with pytest.raises(JobRejected) as error:
await iterate_plan(job(JobKind.ITERATE_PLAN), RecordingRun(), services) await iterate_plan(job(JobKind.ITERATE_PLAN), harness.run, harness.services)
assert str(error.value) == message assert str(error.value) == message
assert repository.saved_workflows == [] assert repository.saved_workflows == []
assert opencode.resume_calls == [] assert harness.opencode.resume_calls == []
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -421,19 +353,22 @@ async def test_iterate_plan_rejects_missing_or_incompatible_plan(
async def test_iterate_plan_rejects_after_agent_pr_is_open_or_merged( async def test_iterate_plan_rejects_after_agent_pr_is_open_or_merged(
tmp_path: Path, blocking_pull: PullRequestInfo tmp_path: Path, blocking_pull: PullRequestInfo
) -> None: ) -> None:
services, repository, _, _, _, opencode = make_services( repository = FakeRepository(
tmp_path,
latest=plan_workflow(), latest=plan_workflow(),
implementations=[implementation_workflow()], implementations=[implementation_workflow()],
pulls={9: blocking_pull}, )
harness = make_workflow_harness(
settings=plan_settings(tmp_path),
repository=repository,
gitea=FakeGitea({9: blocking_pull}),
) )
with pytest.raises(JobRejected) as error: with pytest.raises(JobRejected) as error:
await iterate_plan(job(JobKind.ITERATE_PLAN), RecordingRun(), services) await iterate_plan(job(JobKind.ITERATE_PLAN), harness.run, harness.services)
assert str(error.value) == ( assert str(error.value) == (
"Issue plan iteration is disabled because agent PR #9 is open or merged. " "Issue plan iteration is disabled because agent PR #9 is open or merged. "
"Iterate an open implementation on its PR." "Iterate an open implementation on its PR."
) )
assert repository.latest_calls == 0 assert repository.latest_calls == 0
assert opencode.resume_calls == [] assert harness.opencode.resume_calls == []
+86 -178
View File
@@ -1,34 +1,18 @@
import json import json
from collections.abc import Iterable
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any, cast from typing import Any
import pytest import pytest
from pydantic import BaseModel from pydantic import BaseModel
from agentci.engine.model import Job, JobKind, Workflow, WorkflowKind, WorkflowStatus from agentci.engine.model import Job, JobKind, Workflow, WorkflowKind, WorkflowStatus
from agentci.engine.run import JobRun
from agentci.integrations.gitea.models import CommentInfo, IssueInfo, PullRequestInfo from agentci.integrations.gitea.models import CommentInfo, IssueInfo, PullRequestInfo
from agentci.workflows.model import AgentResult, ReviewReport from agentci.workflows.model import AgentResult, ReviewReport
from agentci.workflows.pull_request import fix_pull_request, iterate_implementation from agentci.workflows.pull_request import fix_pull_request, iterate_implementation
from agentci.workflows.render import JobRejected from agentci.workflows.render import JobRejected
from agentci.workflows.services import WorkflowServices from tests.workflow_support import WorkflowHarness, make_workflow_harness
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: class FakeRepository:
@@ -88,70 +72,6 @@ class FakeGitea:
return IssueInfo(7, "Fix widget", "The widget is broken.", "open") 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: def job(kind: JobKind, *, pr_number: int | None = 12) -> Job:
return Job( return Job(
id="job-pr", id="job-pr",
@@ -231,32 +151,15 @@ def pull(
) )
def make_services( def pull_request_harness(
tmp_path: Path, tmp_path: Path,
repository: FakeRepository,
gitea: FakeGitea,
*, *,
workflow: Workflow | None = None,
pull_info: PullRequestInfo | None = None,
plan: Workflow | None = None,
changed: bool = True, changed: bool = True,
responses: list[BaseModel] | None = None, responses: Iterable[BaseModel] = (),
) -> tuple[ ) -> WorkflowHarness:
WorkflowServices, return make_workflow_harness(
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( settings=SimpleNamespace(
workspaces_dir=tmp_path / "workspaces", workspaces_dir=tmp_path / "workspaces",
implement_model="provider/model", implement_model="provider/model",
@@ -264,13 +167,12 @@ def make_services(
), ),
repository=repository, repository=repository,
gitea=gitea, gitea=gitea,
git=git, changed=changed,
development=development, responses=responses,
prompts=prompts, clone_sha="head-sha",
opencode=opencode, sync_sha="head-sha",
), commit_sha="new-sha",
) )
return services, repository, gitea, git, development, prompts, opencode
def result(summary: str = "# Refine widget") -> AgentResult: def result(summary: str = "# Refine widget") -> AgentResult:
@@ -281,37 +183,52 @@ async def test_iterate_implementation_reuses_sessions_reviews_commits_and_pushes
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
existing = implementation_workflow() existing = implementation_workflow()
services, repository, _, git, development, prompts, opencode = make_services( repository = FakeRepository(workflow=existing, plan=plan_workflow())
gitea = FakeGitea(pull())
harness = pull_request_harness(
tmp_path, tmp_path,
workflow=existing, repository,
plan=plan_workflow(), gitea,
responses=[result(), ReviewReport(summary="Ready", findings=[])], responses=[result(), ReviewReport(summary="Ready", findings=[])],
) )
run = RecordingRun()
body = await iterate_implementation(job(JobKind.ITERATE_IMPLEMENT), run, services) body = await iterate_implementation(
job(JobKind.ITERATE_IMPLEMENT),
harness.run,
harness.services,
)
assert run.linked_workflows == [(existing.id, "synchronizing branch")] assert harness.run.linked_workflows == [(existing.id, "synchronizing branch")]
assert development.workspaces == [existing.workspace_path] assert harness.development.workspaces == [existing.workspace_path]
assert opencode.created_sessions == [] assert harness.opencode.created_sessions == []
assert [call["session_id"] for call in opencode.resume_calls] == [ assert [call["session_id"] for call in harness.opencode.resume_calls] == [
"primary-session", "primary-session",
"reviewer-session", "reviewer-session",
] ]
assert [call["result_type"] for call in opencode.resume_calls] == [ assert [call["result_type"] for call in harness.opencode.resume_calls] == [
AgentResult, AgentResult,
ReviewReport, ReviewReport,
] ]
iterate_prompt = prompts.calls[0] iterate_prompt = harness.prompts.calls[0]
assert iterate_prompt[0] == "implementation_iterate" assert iterate_prompt[0] == "implementation_iterate"
assert iterate_prompt[1]["message"] == "Handle the review." assert iterate_prompt[1]["message"] == "Handle the review."
assert "Handle empty input." in iterate_prompt[1]["context"] review_prompt = harness.prompts.calls[1]
review_prompt = prompts.calls[1]
assert review_prompt[0] == "implementation_review" assert review_prompt[0] == "implementation_review"
assert set(review_prompt[1]) == {"issue_context", "artifact", "pull_context"}
assert review_prompt[1]["issue_context"].startswith("Repository: org/repo\n")
assert review_prompt[1]["artifact"] == "Canonical plan" assert review_prompt[1]["artifact"] == "Canonical plan"
assert "The widget is broken." in review_prompt[1]["issue_context"] assert review_prompt[1]["pull_context"] == iterate_prompt[1]["context"]
assert harness.opencode.resume_calls[1] == {
"session_id": "reviewer-session",
"prompt": "rendered implementation_review",
"model": "provider/model",
"variant": "high",
"workspace": existing.workspace_path,
"schema_name": "review.json",
"result_type": ReviewReport,
}
assert git.calls == [ assert harness.git.calls == [
("sync_branch", existing.workspace_path, "agent/issue-7"), ("sync_branch", existing.workspace_path, "agent/issue-7"),
("has_changes", existing.workspace_path), ("has_changes", existing.workspace_path),
("diff_check", existing.workspace_path), ("diff_check", existing.workspace_path),
@@ -368,23 +285,23 @@ async def test_iterate_implementation_rejects_missing_stale_or_incompatible_work
existing: Workflow | None, existing: Workflow | None,
message: str, message: str,
) -> None: ) -> None:
services, repository, gitea, git, development, _, opencode = make_services( repository = FakeRepository(workflow=existing)
tmp_path, workflow=existing gitea = FakeGitea(pull())
) harness = pull_request_harness(tmp_path, repository, gitea)
with pytest.raises(JobRejected) as error: with pytest.raises(JobRejected) as error:
await iterate_implementation( await iterate_implementation(
job(JobKind.ITERATE_IMPLEMENT, pr_number=pr_number), job(JobKind.ITERATE_IMPLEMENT, pr_number=pr_number),
RecordingRun(), harness.run,
services, harness.services,
) )
assert str(error.value) == message assert str(error.value) == message
assert repository.saved_workflows == [] assert repository.saved_workflows == []
assert gitea.pull_calls == [] assert gitea.pull_calls == []
assert git.calls == [] assert harness.git.calls == []
assert development.workspaces == [] assert harness.development.workspaces == []
assert opencode.resume_calls == [] assert harness.opencode.resume_calls == []
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -409,46 +326,51 @@ async def test_iterate_implementation_rejects_closed_or_stale_branch_before_chec
existing: Workflow, existing: Workflow,
message: str, message: str,
) -> None: ) -> None:
services, repository, _, git, development, _, opencode = make_services( repository = FakeRepository(workflow=existing)
tmp_path, workflow=existing, pull_info=pull_info harness = pull_request_harness(tmp_path, repository, FakeGitea(pull_info))
)
with pytest.raises(JobRejected) as error: with pytest.raises(JobRejected) as error:
await iterate_implementation(job(JobKind.ITERATE_IMPLEMENT), RecordingRun(), services) await iterate_implementation(
job(JobKind.ITERATE_IMPLEMENT),
harness.run,
harness.services,
)
assert str(error.value) == message assert str(error.value) == message
assert repository.saved_workflows == [] assert repository.saved_workflows == []
assert git.calls == [] assert harness.git.calls == []
assert development.workspaces == [] assert harness.development.workspaces == []
assert opencode.resume_calls == [] assert harness.opencode.resume_calls == []
async def test_fix_pull_request_clones_head_runs_agent_commits_and_pushes( async def test_fix_pull_request_clones_head_runs_agent_commits_and_pushes(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
services, repository, _, git, development, prompts, opencode = make_services( repository = FakeRepository()
harness = pull_request_harness(
tmp_path, tmp_path,
repository,
FakeGitea(pull()),
responses=[result("Fix empty input")], responses=[result("Fix empty input")],
) )
run = RecordingRun()
body = await fix_pull_request(job(JobKind.FIX), run, services) body = await fix_pull_request(job(JobKind.FIX), harness.run, harness.services)
workspace = tmp_path / "workspaces" / "fix-job-pr" / "repo" workspace = tmp_path / "workspaces" / "fix-job-pr" / "repo"
assert git.calls == [ assert harness.git.calls == [
("clone", "contributor", "fork", "agent/issue-7", workspace), ("clone", "contributor", "fork", "agent/issue-7", workspace),
("has_changes", workspace), ("has_changes", workspace),
("diff_check", workspace), ("diff_check", workspace),
("commit", workspace, "agent fix: Fix empty input"), ("commit", workspace, "agent fix: Fix empty input"),
("push", workspace, "agent/issue-7", False), ("push", workspace, "agent/issue-7", False),
] ]
assert development.workspaces == [workspace] assert harness.development.workspaces == [workspace]
assert opencode.created_sessions == [(workspace, "fix")] assert harness.opencode.created_sessions == [(workspace, "fix")]
assert run.linked_sessions == ["fix-session"] assert harness.run.linked_sessions == ["fix-session"]
assert opencode.resume_calls[0]["session_id"] == "fix-session" assert harness.opencode.resume_calls[0]["session_id"] == "fix-session"
assert opencode.resume_calls[0]["result_type"] is AgentResult assert harness.opencode.resume_calls[0]["result_type"] is AgentResult
assert prompts.calls[0][0] == "fix" assert harness.prompts.calls[0][0] == "fix"
assert "`src/widget.py:8`: Add a guard." in prompts.calls[0][1]["context"] assert "`src/widget.py:8`: Add a guard." in harness.prompts.calls[0][1]["context"]
assert repository.saved_workflows == [] assert repository.saved_workflows == []
assert body == ( assert body == (
"<!-- agentci:fix workflow=job-pr -->\n" "<!-- agentci:fix workflow=job-pr -->\n"
@@ -457,32 +379,13 @@ async def test_fix_pull_request_clones_head_runs_agent_commits_and_pushes(
) )
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( @pytest.mark.parametrize(
("pr_number", "pull_info", "message"), ("pr_number", "pull_info", "message"),
[ [
(None, pull(), "This command requires a pull request."), (None, pull(), "This command requires a pull request."),
(12, pull(state="closed"), "Fixes require an open 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"], ids=["missing-pr", "closed"],
) )
async def test_fix_pull_request_requires_open_pr_before_clone( async def test_fix_pull_request_requires_open_pr_before_clone(
tmp_path: Path, tmp_path: Path,
@@ -490,13 +393,18 @@ async def test_fix_pull_request_requires_open_pr_before_clone(
pull_info: PullRequestInfo, pull_info: PullRequestInfo,
message: str, message: str,
) -> None: ) -> None:
services, _, gitea, git, development, _, opencode = make_services(tmp_path, pull_info=pull_info) gitea = FakeGitea(pull_info)
harness = pull_request_harness(tmp_path, FakeRepository(), gitea)
with pytest.raises(JobRejected) as error: with pytest.raises(JobRejected) as error:
await fix_pull_request(job(JobKind.FIX, pr_number=pr_number), RecordingRun(), services) await fix_pull_request(
job(JobKind.FIX, pr_number=pr_number),
harness.run,
harness.services,
)
assert str(error.value) == message assert str(error.value) == message
assert git.calls == [] assert harness.git.calls == []
assert development.workspaces == [] assert harness.development.workspaces == []
assert opencode.resume_calls == [] assert harness.opencode.resume_calls == []
assert gitea.pull_calls == ([] if pr_number is None else [("org", "repo", 12)]) assert gitea.pull_calls == ([] if pr_number is None else [("org", "repo", 12)])
+37 -16
View File
@@ -59,32 +59,53 @@ def test_agent_and_final_comments_preserve_protocol_marker() -> None:
) )
def test_pull_request_body_and_result_comment_render_validation() -> None: @pytest.mark.parametrize(
result = AgentResult( ("issue_number", "result", "sha", "expected_body", "expected_comment"),
[
pytest.param(
17,
AgentResult(
summary_markdown="Implemented the widget fix.", summary_markdown="Implemented the widget fix.",
tests=["pytest: passed", "ruff: passed"], tests=["pytest: passed", "ruff: passed"],
) ),
"abc123",
assert pull_request_body(17, result) == ( (
"Closes #17\n\n" "Closes #17\n\n"
"## Implementation\n\nImplemented the widget fix.\n\n" "## Implementation\n\nImplemented the widget fix.\n\n"
"## Validation\n\n- pytest: passed\n- ruff: passed\n\n" "## Validation\n\n- pytest: passed\n- ruff: passed\n\n"
"_Created by Agent CI._" "_Created by Agent CI._"
) ),
assert result_comment(result, sha="abc123") == ( (
"## Agent result\n\nImplemented the widget fix.\n\n" "## Agent result\n\nImplemented the widget fix.\n\n"
"## Validation\n\n- pytest: passed\n- ruff: passed\n\n" "## Validation\n\n- pytest: passed\n- ruff: passed\n\n"
"Commit: `abc123`" "Commit: `abc123`"
),
id="reported-validation",
),
pytest.param(
2,
AgentResult(summary_markdown="Applied the change.", tests=[]),
None,
(
"Closes #2\n\n"
"## Implementation\n\nApplied the change.\n\n"
"## Validation\n\n- Not reported\n\n"
"_Created by Agent CI._"
),
"## Agent result\n\nApplied the change.\n\n## Validation\n\n- Not reported",
id="missing-validation",
),
],
) )
def test_result_renderers_render_reported_and_missing_validation(
issue_number: int,
def test_renderers_report_missing_validation() -> None: result: AgentResult,
result = AgentResult(summary_markdown="Applied the change.", tests=[]) sha: str | None,
expected_body: str,
assert "## Validation\n\n- Not reported" in pull_request_body(2, result) expected_comment: str,
assert result_comment(result) == ( ) -> None:
"## Agent result\n\nApplied the change.\n\n## Validation\n\n- Not reported" assert pull_request_body(issue_number, result) == expected_body
) assert result_comment(result, sha=sha) == expected_comment
@pytest.mark.parametrize( @pytest.mark.parametrize(
+178
View File
@@ -0,0 +1,178 @@
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
from pathlib import Path
from types import SimpleNamespace
from typing import Any, cast
from pydantic import BaseModel
from agentci.engine.model import Workflow
from agentci.engine.run import JobRun
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 ScriptedOpenCode:
def __init__(
self,
responses: Iterable[BaseModel] = (),
trace: list[tuple[object, ...]] | None = None,
) -> None:
self.responses = list(responses)
self.created_sessions: list[tuple[Path, str]] = []
self.resume_calls: list[dict[str, Any]] = []
self.trace = trace if trace is not None else []
async def create_session(self, workspace: Path, title: str) -> str:
session_id = f"{title}-session"
self.created_sessions.append((workspace, title))
self.trace.append(("create_session", title, session_id))
return session_id
async def resume(self, **values: Any) -> BaseModel:
self.resume_calls.append(values)
self.trace.append(("resume", values["session_id"], values["result_type"]))
assert self.responses, "unexpected OpenCode resume call"
response = self.responses.pop(0)
assert isinstance(response, values["result_type"]), (
f"expected {values['result_type'].__name__}, got {type(response).__name__}"
)
return response
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 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 RecordingGit:
def __init__(
self,
*,
changed: bool = True,
clone_sha: str = "base-sha",
sync_sha: str = "head-sha",
commit_sha: str = "commit-sha",
) -> None:
self.changed = changed
self.clone_sha = clone_sha
self.sync_sha = sync_sha
self.commit_sha = commit_sha
self.calls: list[tuple[object, ...]] = []
async def clone(self, owner: str, repo: str, branch: str, destination: Path) -> str:
self.calls.append(("clone", owner, repo, branch, destination))
return self.clone_sha
async def create_branch(self, workspace: Path, branch: str) -> None:
self.calls.append(("create_branch", workspace, branch))
async def sync_branch(self, workspace: Path, branch: str) -> str:
self.calls.append(("sync_branch", workspace, branch))
return self.sync_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 self.commit_sha
async def push(self, workspace: Path, branch: str, *, set_upstream: bool = False) -> None:
self.calls.append(("push", workspace, branch, set_upstream))
@dataclass(frozen=True)
class WorkflowHarness:
services: WorkflowServices
run: RecordingRun
git: RecordingGit
development: RecordingDevelopment
prompts: RecordingPrompts
opencode: ScriptedOpenCode
trace: list[tuple[object, ...]]
def make_workflow_harness(
*,
settings: SimpleNamespace,
repository: object,
gitea: object,
responses: Iterable[BaseModel] = (),
trace: list[tuple[object, ...]] | None = None,
changed: bool = True,
clone_sha: str = "base-sha",
sync_sha: str = "head-sha",
commit_sha: str = "commit-sha",
) -> WorkflowHarness:
shared_trace = trace if trace is not None else []
run = RecordingRun()
git = RecordingGit(
changed=changed,
clone_sha=clone_sha,
sync_sha=sync_sha,
commit_sha=commit_sha,
)
development = RecordingDevelopment()
prompts = RecordingPrompts()
opencode = ScriptedOpenCode(responses, shared_trace)
services = cast(
WorkflowServices,
SimpleNamespace(
settings=settings,
repository=repository,
gitea=gitea,
git=git,
development=development,
prompts=prompts,
opencode=opencode,
),
)
return WorkflowHarness(
services=services,
run=run,
git=git,
development=development,
prompts=prompts,
opencode=opencode,
trace=shared_trace,
)