Files
agentci/tests/test_workflow_plan.py
T
StanPonomarev ce9f1e3d20
Publish container image / Build and push (push) Successful in 32s
refactor tests
2026-07-26 23:49:40 +02:00

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 == []