rewrite phase 1

This commit is contained in:
2026-07-22 23:10:23 +02:00
parent 7527831af6
commit 98ac4abca1
89 changed files with 9179 additions and 2795 deletions
+392
View File
@@ -0,0 +1,392 @@
import json
from pathlib import Path
from types import SimpleNamespace
from typing import Any, cast
import pytest
from agentci.engine.model import Job, JobKind, Workflow, WorkflowKind, WorkflowStatus
from agentci.engine.run import JobRun
from agentci.gitea import CommentInfo, IssueInfo, PullRequestInfo
from agentci.workflows.implementation import implement
from agentci.workflows.model import AgentResult, ReviewReport
from agentci.workflows.render import JobRejected
from agentci.workflows.services import WorkflowServices
class RecordingRun(JobRun):
def __init__(self) -> None:
self.stages: list[str] = []
self.created_workflows: list[tuple[Workflow, str]] = []
self.linked_sessions: list[str] = []
async def stage(self, stage: str) -> None:
self.stages.append(stage)
async def create_workflow(self, workflow: Workflow, stage: str) -> None:
self.created_workflows.append((workflow, stage))
async def link_session(self, session_id: str) -> None:
self.linked_sessions.append(session_id)
class FakeRepository:
def __init__(
self,
*,
implementations: list[Workflow] | None = None,
plan: Workflow | None = None,
) -> None:
self.implementations = implementations or []
self.plan = plan
self.saved_workflows: list[Workflow] = []
async def implementation_workflows(self, *_args: object) -> list[Workflow]:
return self.implementations
async def latest_workflow(self, *_args: object) -> Workflow | None:
return self.plan
async def operational_comment_ids(self, *_args: object) -> set[int]:
return {99}
async def save_workflow(self, workflow: Workflow) -> None:
self.saved_workflows.append(workflow)
class FakeGitea:
def __init__(
self,
*,
existing_pulls: dict[int, PullRequestInfo] | None = None,
) -> None:
self.existing_pulls = existing_pulls or {}
self.created_pulls: list[tuple[str, str, dict[str, str]]] = []
self.default_branch_calls: list[tuple[str, str]] = []
async def default_branch(self, owner: str, repo: str) -> str:
self.default_branch_calls.append((owner, repo))
return "main"
async def issue(self, *_args: object) -> IssueInfo:
return IssueInfo(7, "Fix widget", "The widget is broken.", "open")
async def issue_comments(self, *_args: object) -> list[CommentInfo]:
return [
CommentInfo(12, "alice", "Please cover empty input.", "2026-07-01"),
CommentInfo(99, "agentci", "Job queued", "2026-07-02"),
]
async def pull_request(self, _owner: str, _repo: str, number: int) -> PullRequestInfo:
return self.existing_pulls[number]
async def create_pull_request(self, owner: str, repo: str, **values: str) -> PullRequestInfo:
self.created_pulls.append((owner, repo, values))
return pull(number=42, branch=values["head"])
class RecordingGit:
def __init__(self, *, changed: bool = True) -> None:
self.changed = changed
self.calls: list[tuple[Any, ...]] = []
async def clone(self, owner: str, repo: str, branch: str, destination: Path) -> str:
self.calls.append(("clone", owner, repo, branch, destination))
return "base-sha"
async def create_branch(self, workspace: Path, branch: str) -> None:
self.calls.append(("create_branch", workspace, branch))
async def has_changes(self, workspace: Path) -> bool:
self.calls.append(("has_changes", workspace))
return self.changed
async def diff_check(self, workspace: Path) -> None:
self.calls.append(("diff_check", workspace))
async def commit(self, workspace: Path, message: str) -> str:
self.calls.append(("commit", workspace, message))
return "commit-sha"
async def push(self, workspace: Path, branch: str, *, set_upstream: bool = False) -> None:
self.calls.append(("push", workspace, branch, set_upstream))
class RecordingDevelopment:
description = "Python 3.13"
def __init__(self) -> None:
self.workspaces: list[Path] = []
async def prepare(self, workspace: Path) -> None:
self.workspaces.append(workspace)
class RecordingPrompts:
def __init__(self) -> None:
self.calls: list[tuple[str, dict[str, str]]] = []
def render(self, name: str, **values: str) -> str:
self.calls.append((name, values))
return f"rendered {name}"
class RecordingOpenCode:
def __init__(self, result: AgentResult, report: ReviewReport) -> None:
self.result = result
self.report = report
self.created_sessions: list[tuple[Path, str]] = []
self.resume_calls: list[dict[str, Any]] = []
async def create_session(self, workspace: Path, title: str) -> str:
self.created_sessions.append((workspace, title))
return f"{title}-session"
async def resume(self, **values: Any) -> AgentResult | ReviewReport:
self.resume_calls.append(values)
if values["result_type"] is AgentResult:
return self.result
assert values["result_type"] is ReviewReport
return self.report
def job() -> Job:
return Job(
id="job-1",
kind=JobKind.IMPLEMENT,
target_key="org/repo:issue:7",
repo_owner="org",
repo_name="repo",
issue_number=7,
pr_number=None,
requester="alice",
comment_id=10,
delivery_id="delivery-1",
receive_sequence=1,
command_body="/agent implement",
message="Keep the change focused.",
)
def workflow(*, pr_number: int | None = None) -> Workflow:
return Workflow(
id=f"old-{pr_number}",
kind=WorkflowKind.IMPLEMENT,
repo_owner="org",
repo_name="repo",
issue_number=7,
workspace_path=Path("/old/repo"),
base_sha="old-sha",
pr_number=pr_number,
status=WorkflowStatus.COMPLETED,
)
def pull(
*,
number: int = 8,
state: str = "open",
merged: bool = False,
branch: str = "agent/old",
) -> PullRequestInfo:
return PullRequestInfo(
number=number,
title="Existing PR",
body="Body",
state=state,
merged=merged,
base_branch="main",
head_branch=branch,
head_sha="head-sha",
head_owner="org",
head_repo="repo",
)
def make_services(
tmp_path: Path,
*,
changed: bool = True,
implementations: list[Workflow] | None = None,
existing_pulls: dict[int, PullRequestInfo] | None = None,
plan: Workflow | None = None,
) -> tuple[
WorkflowServices,
FakeRepository,
FakeGitea,
RecordingGit,
RecordingDevelopment,
RecordingPrompts,
RecordingOpenCode,
]:
repository = FakeRepository(implementations=implementations, plan=plan)
gitea = FakeGitea(existing_pulls=existing_pulls)
git = RecordingGit(changed=changed)
development = RecordingDevelopment()
prompts = RecordingPrompts()
opencode = RecordingOpenCode(
AgentResult(
summary_markdown="# Implement widget\n\nHandled empty input.",
tests=["pytest: passed"],
),
ReviewReport(summary="Ready", findings=[]),
)
services = cast(
WorkflowServices,
SimpleNamespace(
settings=SimpleNamespace(
branch_prefix="agent",
workspaces_dir=tmp_path / "workspaces",
gitea_url="https://git.example.test",
implement_model="provider/model",
implement_variant="high",
implement_review_rounds=2,
),
repository=repository,
gitea=gitea,
git=git,
opencode=opencode,
prompts=prompts,
development=development,
),
)
return services, repository, gitea, git, development, prompts, opencode
async def test_initial_implementation_completes_review_commit_push_and_pr(
tmp_path: Path,
) -> None:
stale = workflow(pr_number=8)
services, repository, gitea, git, development, prompts, opencode = make_services(
tmp_path,
implementations=[stale],
existing_pulls={8: pull(state="closed")},
)
run = RecordingRun()
body = await implement(job(), run, services)
assert len(run.created_workflows) == 1
created, created_stage = run.created_workflows[0]
assert created_stage == "installing development environment"
assert created.status is WorkflowStatus.ACTIVE
assert created.branch == f"agent/issue-7-{created.id[:8]}"
assert created.workspace_path == tmp_path / "workspaces" / created.id / "repo"
assert created.base_sha == "base-sha"
assert development.workspaces == [created.workspace_path]
assert run.linked_sessions == ["implementation-session"]
assert opencode.created_sessions == [
(created.workspace_path, "implementation"),
(created.workspace_path, "implementation-review"),
]
assert [call["result_type"] for call in opencode.resume_calls] == [
AgentResult,
ReviewReport,
]
initial_prompt = prompts.calls[0]
assert initial_prompt[0] == "implement_initial"
assert initial_prompt[1]["artifact"] == "(no canonical plan)"
assert initial_prompt[1]["request"] == "Keep the change focused."
assert "Please cover empty input." in initial_prompt[1]["context"]
assert "Job queued" not in initial_prompt[1]["context"]
assert git.calls == [
("clone", "org", "repo", "main", created.workspace_path),
("create_branch", created.workspace_path, created.branch),
("has_changes", created.workspace_path),
("diff_check", created.workspace_path),
("commit", created.workspace_path, "agent: Implement widget"),
("push", created.workspace_path, created.branch, True),
]
assert gitea.created_pulls == [
(
"org",
"repo",
{
"title": "Agent: Fix widget",
"body": (
"Closes #7\n\n"
"## Implementation\n\n# Implement widget\n\nHandled empty input.\n\n"
"## Validation\n\n- pytest: passed\n\n_Created by Agent CI._"
),
"head": created.branch,
"base": "main",
},
)
]
completed = repository.saved_workflows[-1]
assert completed.id == created.id
assert completed.status is WorkflowStatus.COMPLETED
assert completed.pr_number == 42
assert completed.primary_session_id == "implementation-session"
assert completed.reviewer_session_id == "implementation-review-session"
assert AgentResult.model_validate_json(completed.artifact or "").tests == ["pytest: passed"]
assert json.loads(completed.review_json or "") == {
"summary": "Ready",
"findings": [],
}
assert body == (
f"<!-- agentci:implementation workflow={created.id} -->\n"
"Pull request created: https://git.example.test/org/repo/pulls/42\n\n"
"## Agent result\n\n# Implement widget\n\nHandled empty input.\n\n"
"## Validation\n\n- pytest: passed\n\nCommit: `commit-sha`"
)
async def test_initial_implementation_rejects_clean_worktree_before_commit_or_pr(
tmp_path: Path,
) -> None:
services, repository, gitea, git, _, _, _ = make_services(tmp_path, changed=False)
run = RecordingRun()
with pytest.raises(
JobRejected,
match="OpenCode completed without producing any file changes",
):
await implement(job(), run, services)
assert [call[0] for call in git.calls] == [
"clone",
"create_branch",
"has_changes",
]
assert gitea.created_pulls == []
assert repository.saved_workflows
assert all(item.status is WorkflowStatus.ACTIVE for item in repository.saved_workflows)
assert "creating pull request" not in run.stages
@pytest.mark.parametrize(
("existing_pull", "message"),
[
(
pull(state="open"),
"Agent PR #8 is already open. Use `/agent iterate` on that pull request.",
),
(
pull(state="closed", merged=True),
"Agent PR #8 has already been merged for this issue.",
),
],
)
async def test_initial_implementation_rejects_duplicate_agent_pr_before_clone(
tmp_path: Path,
existing_pull: PullRequestInfo,
message: str,
) -> None:
services, _, gitea, git, development, _, opencode = make_services(
tmp_path,
implementations=[workflow(pr_number=8)],
existing_pulls={8: existing_pull},
)
with pytest.raises(JobRejected) as error:
await implement(job(), RecordingRun(), services)
assert str(error.value) == message
assert git.calls == []
assert development.workspaces == []
assert opencode.resume_calls == []
assert gitea.default_branch_calls == []