375 lines
12 KiB
Python
375 lines
12 KiB
Python
import json
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from agentci.engine.model import Job, JobKind, Workflow, WorkflowKind, WorkflowStatus
|
|
from agentci.integrations.gitea.models import CommentInfo, IssueInfo, PullRequestInfo
|
|
from agentci.workflows.model import DiscussionReply, PlanArtifact, ReviewReport
|
|
from agentci.workflows.plan import create_plan, discuss_plan, iterate_plan
|
|
from agentci.workflows.render import JobRejected
|
|
from tests.workflow_support import make_workflow_harness
|
|
|
|
|
|
class FakeRepository:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
latest: Workflow | None = None,
|
|
implementations: list[Workflow] | None = None,
|
|
) -> None:
|
|
self.latest = latest
|
|
self.implementations = implementations or []
|
|
self.saved_workflows: list[Workflow] = []
|
|
self.latest_calls = 0
|
|
|
|
async def latest_workflow(self, *_args: object) -> Workflow | None:
|
|
self.latest_calls += 1
|
|
return self.latest
|
|
|
|
async def implementation_workflows(self, *_args: object) -> list[Workflow]:
|
|
return self.implementations
|
|
|
|
async def operational_comment_ids(self, *_args: object) -> set[int]:
|
|
return {91}
|
|
|
|
async def save_workflow(self, workflow: Workflow) -> None:
|
|
self.saved_workflows.append(workflow)
|
|
self.latest = workflow
|
|
|
|
|
|
class FakeGitea:
|
|
def __init__(self, pulls: dict[int, PullRequestInfo] | None = None) -> None:
|
|
self.pulls = pulls or {}
|
|
self.default_branch_calls: list[tuple[str, str]] = []
|
|
self.pull_calls: list[int] = []
|
|
|
|
async def default_branch(self, owner: str, repo: str) -> str:
|
|
self.default_branch_calls.append((owner, repo))
|
|
return "trunk"
|
|
|
|
async def issue(self, *_args: object) -> IssueInfo:
|
|
return IssueInfo(3, "Plan feature", "Build the feature.", "open")
|
|
|
|
async def issue_comments(self, *_args: object) -> list[CommentInfo]:
|
|
return [
|
|
CommentInfo(4, "alice", "Use the existing API.", "2026-07-01"),
|
|
CommentInfo(91, "agentci", "Job queued", "2026-07-02"),
|
|
]
|
|
|
|
async def pull_request(self, _owner: str, _repo: str, number: int) -> PullRequestInfo:
|
|
self.pull_calls.append(number)
|
|
return self.pulls[number]
|
|
|
|
|
|
def job(kind: JobKind, *, message: str | None = "Please be specific.") -> Job:
|
|
return Job(
|
|
id="job-plan",
|
|
kind=kind,
|
|
target_key="org/repo:issue:3",
|
|
repo_owner="org",
|
|
repo_name="repo",
|
|
issue_number=3,
|
|
pr_number=None,
|
|
requester="alice",
|
|
comment_id=5,
|
|
delivery_id="delivery-plan",
|
|
receive_sequence=1,
|
|
command_body="/agent plan",
|
|
message=message,
|
|
)
|
|
|
|
|
|
def plan_workflow(
|
|
*,
|
|
runtime: str = "opencode",
|
|
primary_session_id: str | None = "primary-session",
|
|
reviewer_session_id: str | None = "reviewer-session",
|
|
artifact: str | None = "Original plan",
|
|
) -> Workflow:
|
|
return Workflow(
|
|
id="plan-flow",
|
|
kind=WorkflowKind.PLAN,
|
|
repo_owner="org",
|
|
repo_name="repo",
|
|
issue_number=3,
|
|
workspace_path=Path("/workspace/plan"),
|
|
base_sha="base-sha",
|
|
runtime=runtime,
|
|
primary_session_id=primary_session_id,
|
|
reviewer_session_id=reviewer_session_id,
|
|
artifact=artifact,
|
|
review_json='{"summary":"Prior","findings":[]}',
|
|
status=WorkflowStatus.COMPLETED,
|
|
)
|
|
|
|
|
|
def implementation_workflow(pr_number: int | None = 9) -> Workflow:
|
|
return Workflow(
|
|
id="implementation-flow",
|
|
kind=WorkflowKind.IMPLEMENT,
|
|
repo_owner="org",
|
|
repo_name="repo",
|
|
issue_number=3,
|
|
workspace_path=Path("/workspace/implementation"),
|
|
base_sha="base",
|
|
pr_number=pr_number,
|
|
status=WorkflowStatus.COMPLETED,
|
|
)
|
|
|
|
|
|
def pull(*, state: str = "open", merged: bool = False) -> PullRequestInfo:
|
|
return PullRequestInfo(
|
|
number=9,
|
|
title="Agent implementation",
|
|
body="Body",
|
|
state=state,
|
|
merged=merged,
|
|
base_branch="trunk",
|
|
head_branch="agent/feature",
|
|
head_sha="head",
|
|
head_owner="org",
|
|
head_repo="repo",
|
|
)
|
|
|
|
|
|
def plan_settings(tmp_path: Path) -> SimpleNamespace:
|
|
return SimpleNamespace(
|
|
workspaces_dir=tmp_path / "workspaces",
|
|
plan_model="provider/model",
|
|
plan_variant="high",
|
|
plan_review_rounds=3,
|
|
)
|
|
|
|
|
|
async def test_create_plan_completes_primary_and_independent_review(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository = FakeRepository()
|
|
gitea = FakeGitea()
|
|
harness = make_workflow_harness(
|
|
settings=plan_settings(tmp_path),
|
|
repository=repository,
|
|
gitea=gitea,
|
|
responses=[
|
|
PlanArtifact(plan_markdown="# Complete plan"),
|
|
ReviewReport(summary="Ready", findings=[]),
|
|
],
|
|
)
|
|
|
|
body = await create_plan(job(JobKind.PLAN), harness.run, harness.services)
|
|
|
|
created, stage = harness.run.created_workflows[0]
|
|
assert stage == "planning"
|
|
assert created.kind is WorkflowKind.PLAN
|
|
assert created.workspace_path == tmp_path / "workspaces" / created.id / "repo"
|
|
assert harness.git.calls == [("clone", "org", "repo", "trunk", created.workspace_path)]
|
|
assert gitea.default_branch_calls == [("org", "repo")]
|
|
assert harness.run.linked_sessions == ["plan-session"]
|
|
assert harness.opencode.created_sessions == [
|
|
(created.workspace_path, "plan"),
|
|
(created.workspace_path, "plan-review"),
|
|
]
|
|
assert [call["result_type"] for call in harness.opencode.resume_calls] == [
|
|
PlanArtifact,
|
|
ReviewReport,
|
|
]
|
|
assert harness.prompts.calls[0][0] == "plan_initial"
|
|
assert harness.prompts.calls[0][1]["request"] == "Please be specific."
|
|
assert harness.prompts.calls[0][1]["context"].startswith("Repository: org/repo\n")
|
|
|
|
completed = repository.saved_workflows[-1]
|
|
assert completed.status is WorkflowStatus.COMPLETED
|
|
assert completed.primary_session_id == "plan-session"
|
|
assert completed.reviewer_session_id == "plan-review-session"
|
|
assert completed.artifact == "# Complete plan"
|
|
assert json.loads(completed.review_json or "") == {
|
|
"summary": "Ready",
|
|
"findings": [],
|
|
}
|
|
assert body == f"<!-- agentci:plan workflow={created.id} -->\n# Complete plan"
|
|
|
|
|
|
async def test_discuss_plan_resumes_primary_session_without_replacing_artifact(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
existing = plan_workflow()
|
|
repository = FakeRepository(latest=existing)
|
|
harness = make_workflow_harness(
|
|
settings=plan_settings(tmp_path),
|
|
repository=repository,
|
|
gitea=FakeGitea(),
|
|
responses=[DiscussionReply(markdown="The API remains compatible.")],
|
|
)
|
|
|
|
body = await discuss_plan(job(JobKind.DISCUSS), harness.run, harness.services)
|
|
|
|
assert harness.run.linked_workflows == [(existing.id, "discussing")]
|
|
assert harness.opencode.created_sessions == []
|
|
assert harness.opencode.resume_calls[0]["session_id"] == "primary-session"
|
|
assert harness.opencode.resume_calls[0]["schema_name"] == "discussion.json"
|
|
assert harness.prompts.calls == [
|
|
(
|
|
"discuss",
|
|
{"artifact": "Original plan", "message": "Please be specific."},
|
|
)
|
|
]
|
|
assert repository.saved_workflows == []
|
|
assert body == ("<!-- agentci:discussion workflow=plan-flow -->\nThe API remains compatible.")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("latest", "message"),
|
|
[
|
|
(None, "No completed plan exists. Start with `/agent plan`."),
|
|
(
|
|
plan_workflow(runtime="codex"),
|
|
"The latest plan predates OpenCode and cannot be resumed; start a new `/agent plan`.",
|
|
),
|
|
(
|
|
plan_workflow(primary_session_id=None),
|
|
"The latest plan cannot be resumed; start a new `/agent plan`.",
|
|
),
|
|
(
|
|
plan_workflow(artifact=None),
|
|
"The latest plan cannot be resumed; start a new `/agent plan`.",
|
|
),
|
|
],
|
|
)
|
|
async def test_discuss_plan_rejects_missing_or_incompatible_plan(
|
|
tmp_path: Path,
|
|
latest: Workflow | None,
|
|
message: str,
|
|
) -> None:
|
|
repository = FakeRepository(latest=latest)
|
|
harness = make_workflow_harness(
|
|
settings=plan_settings(tmp_path),
|
|
repository=repository,
|
|
gitea=FakeGitea(),
|
|
)
|
|
|
|
with pytest.raises(JobRejected) as error:
|
|
await discuss_plan(job(JobKind.DISCUSS), harness.run, harness.services)
|
|
|
|
assert str(error.value) == message
|
|
assert harness.opencode.resume_calls == []
|
|
|
|
|
|
async def test_iterate_plan_revises_then_reuses_reviewer_session(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
existing = plan_workflow()
|
|
repository = FakeRepository(
|
|
latest=existing,
|
|
implementations=[implementation_workflow(), implementation_workflow(None)],
|
|
)
|
|
gitea = FakeGitea({9: pull(state="closed")})
|
|
harness = make_workflow_harness(
|
|
settings=plan_settings(tmp_path),
|
|
repository=repository,
|
|
gitea=gitea,
|
|
responses=[
|
|
PlanArtifact(plan_markdown="# Revised plan"),
|
|
ReviewReport(summary="Ready", findings=[]),
|
|
],
|
|
)
|
|
|
|
body = await iterate_plan(
|
|
job(JobKind.ITERATE_PLAN, message=None),
|
|
harness.run,
|
|
harness.services,
|
|
)
|
|
|
|
assert gitea.pull_calls == [9]
|
|
assert harness.run.linked_workflows == [(existing.id, "iterating plan")]
|
|
assert harness.opencode.created_sessions == []
|
|
assert [call["session_id"] for call in harness.opencode.resume_calls] == [
|
|
"primary-session",
|
|
"reviewer-session",
|
|
]
|
|
assert [call["result_type"] for call in harness.opencode.resume_calls] == [
|
|
PlanArtifact,
|
|
ReviewReport,
|
|
]
|
|
iterate_prompt = harness.prompts.calls[0]
|
|
assert iterate_prompt[0] == "plan_iterate"
|
|
assert iterate_prompt[1]["artifact"] == "Original plan"
|
|
assert iterate_prompt[1]["message"] == ("(refine using the latest discussion and prior review)")
|
|
assert json.loads(iterate_prompt[1]["review"]) == {
|
|
"summary": "Prior",
|
|
"findings": [],
|
|
}
|
|
completed = repository.saved_workflows[-1]
|
|
assert completed.artifact == "# Revised plan"
|
|
assert completed.status is WorkflowStatus.COMPLETED
|
|
assert body == "<!-- agentci:plan workflow=plan-flow -->\n# Revised plan"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("latest", "message"),
|
|
[
|
|
(None, "No completed plan exists. Start with `/agent plan`."),
|
|
(
|
|
plan_workflow(runtime="codex"),
|
|
"The latest plan predates OpenCode; start a new plan.",
|
|
),
|
|
(
|
|
plan_workflow(primary_session_id=None),
|
|
"The latest plan is missing resumable sessions; start a new plan.",
|
|
),
|
|
(
|
|
plan_workflow(reviewer_session_id=None),
|
|
"The latest plan is missing resumable sessions; start a new plan.",
|
|
),
|
|
(plan_workflow(artifact=None), "The latest plan has no saved artifact."),
|
|
],
|
|
)
|
|
async def test_iterate_plan_rejects_missing_or_incompatible_plan(
|
|
tmp_path: Path,
|
|
latest: Workflow | None,
|
|
message: str,
|
|
) -> None:
|
|
repository = FakeRepository(latest=latest)
|
|
harness = make_workflow_harness(
|
|
settings=plan_settings(tmp_path),
|
|
repository=repository,
|
|
gitea=FakeGitea(),
|
|
)
|
|
|
|
with pytest.raises(JobRejected) as error:
|
|
await iterate_plan(job(JobKind.ITERATE_PLAN), harness.run, harness.services)
|
|
|
|
assert str(error.value) == message
|
|
assert repository.saved_workflows == []
|
|
assert harness.opencode.resume_calls == []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"blocking_pull",
|
|
[pull(state="open"), pull(state="closed", merged=True)],
|
|
ids=["open", "merged"],
|
|
)
|
|
async def test_iterate_plan_rejects_after_agent_pr_is_open_or_merged(
|
|
tmp_path: Path, blocking_pull: PullRequestInfo
|
|
) -> None:
|
|
repository = FakeRepository(
|
|
latest=plan_workflow(),
|
|
implementations=[implementation_workflow()],
|
|
)
|
|
harness = make_workflow_harness(
|
|
settings=plan_settings(tmp_path),
|
|
repository=repository,
|
|
gitea=FakeGitea({9: blocking_pull}),
|
|
)
|
|
|
|
with pytest.raises(JobRejected) as error:
|
|
await iterate_plan(job(JobKind.ITERATE_PLAN), harness.run, harness.services)
|
|
|
|
assert str(error.value) == (
|
|
"Issue plan iteration is disabled because agent PR #9 is open or merged. "
|
|
"Iterate an open implementation on its PR."
|
|
)
|
|
assert repository.latest_calls == 0
|
|
assert harness.opencode.resume_calls == []
|