This commit is contained in:
+74
-139
@@ -1,38 +1,15 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agentci.engine.model import Job, JobKind, Workflow, WorkflowKind, WorkflowStatus
|
||||
from agentci.engine.run import JobRun
|
||||
from agentci.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 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)
|
||||
from tests.workflow_support import make_workflow_harness
|
||||
|
||||
|
||||
class FakeRepository:
|
||||
@@ -86,41 +63,6 @@ class FakeGitea:
|
||||
return self.pulls[number]
|
||||
|
||||
|
||||
class RecordingGit:
|
||||
def __init__(self) -> None:
|
||||
self.clone_calls: list[tuple[str, str, str, Path]] = []
|
||||
|
||||
async def clone(self, owner: str, repo: str, branch: str, destination: Path) -> str:
|
||||
self.clone_calls.append((owner, repo, branch, destination))
|
||||
return "base-sha"
|
||||
|
||||
|
||||
class RecordingPrompts:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, dict[str, str]]] = []
|
||||
|
||||
def render(self, name: str, **values: str) -> str:
|
||||
self.calls.append((name, values))
|
||||
return f"rendered {name}"
|
||||
|
||||
|
||||
class RecordingOpenCode:
|
||||
def __init__(self, responses: list[BaseModel] | None = None) -> None:
|
||||
self.responses = list(responses or [])
|
||||
self.created_sessions: list[tuple[Path, str]] = []
|
||||
self.resume_calls: list[dict[str, Any]] = []
|
||||
|
||||
async def create_session(self, workspace: Path, title: str) -> str:
|
||||
self.created_sessions.append((workspace, title))
|
||||
return f"{title}-session"
|
||||
|
||||
async def resume(self, **values: Any) -> BaseModel:
|
||||
self.resume_calls.append(values)
|
||||
response = self.responses.pop(0)
|
||||
assert isinstance(response, values["result_type"])
|
||||
return response
|
||||
|
||||
|
||||
def job(kind: JobKind, *, message: str | None = "Please be specific.") -> Job:
|
||||
return Job(
|
||||
id="job-plan",
|
||||
@@ -192,78 +134,50 @@ def pull(*, state: str = "open", merged: bool = False) -> PullRequestInfo:
|
||||
)
|
||||
|
||||
|
||||
def make_services(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
latest: Workflow | None = None,
|
||||
implementations: list[Workflow] | None = None,
|
||||
pulls: dict[int, PullRequestInfo] | None = None,
|
||||
responses: list[BaseModel] | None = None,
|
||||
) -> tuple[
|
||||
WorkflowServices,
|
||||
FakeRepository,
|
||||
FakeGitea,
|
||||
RecordingGit,
|
||||
RecordingPrompts,
|
||||
RecordingOpenCode,
|
||||
]:
|
||||
repository = FakeRepository(latest=latest, implementations=implementations)
|
||||
gitea = FakeGitea(pulls)
|
||||
git = RecordingGit()
|
||||
prompts = RecordingPrompts()
|
||||
opencode = RecordingOpenCode(responses)
|
||||
services = cast(
|
||||
WorkflowServices,
|
||||
SimpleNamespace(
|
||||
settings=SimpleNamespace(
|
||||
workspaces_dir=tmp_path / "workspaces",
|
||||
plan_model="provider/model",
|
||||
plan_variant="high",
|
||||
plan_review_rounds=3,
|
||||
),
|
||||
repository=repository,
|
||||
gitea=gitea,
|
||||
git=git,
|
||||
prompts=prompts,
|
||||
opencode=opencode,
|
||||
),
|
||||
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,
|
||||
)
|
||||
return services, repository, gitea, git, prompts, opencode
|
||||
|
||||
|
||||
async def test_create_plan_completes_primary_and_independent_review(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
services, repository, gitea, git, prompts, opencode = make_services(
|
||||
tmp_path,
|
||||
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=[]),
|
||||
],
|
||||
)
|
||||
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 created.kind is WorkflowKind.PLAN
|
||||
assert created.workspace_path == tmp_path / "workspaces" / created.id / "repo"
|
||||
assert git.clone_calls == [("org", "repo", "trunk", created.workspace_path)]
|
||||
assert harness.git.calls == [("clone", "org", "repo", "trunk", created.workspace_path)]
|
||||
assert gitea.default_branch_calls == [("org", "repo")]
|
||||
assert run.linked_sessions == ["plan-session"]
|
||||
assert opencode.created_sessions == [
|
||||
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 opencode.resume_calls] == [
|
||||
assert [call["result_type"] for call in harness.opencode.resume_calls] == [
|
||||
PlanArtifact,
|
||||
ReviewReport,
|
||||
]
|
||||
assert prompts.calls[0][0] == "plan_initial"
|
||||
assert prompts.calls[0][1]["request"] == "Please be specific."
|
||||
assert "Use the existing API." in prompts.calls[0][1]["context"]
|
||||
assert "Job queued" not in prompts.calls[0][1]["context"]
|
||||
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
|
||||
@@ -281,20 +195,21 @@ async def test_discuss_plan_resumes_primary_session_without_replacing_artifact(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
existing = plan_workflow()
|
||||
services, repository, _, _, prompts, opencode = make_services(
|
||||
tmp_path,
|
||||
latest=existing,
|
||||
repository = FakeRepository(latest=existing)
|
||||
harness = make_workflow_harness(
|
||||
settings=plan_settings(tmp_path),
|
||||
repository=repository,
|
||||
gitea=FakeGitea(),
|
||||
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 opencode.created_sessions == []
|
||||
assert opencode.resume_calls[0]["session_id"] == "primary-session"
|
||||
assert opencode.resume_calls[0]["schema_name"] == "discussion.json"
|
||||
assert prompts.calls == [
|
||||
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."},
|
||||
@@ -327,45 +242,57 @@ async def test_discuss_plan_rejects_missing_or_incompatible_plan(
|
||||
latest: Workflow | None,
|
||||
message: str,
|
||||
) -> 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:
|
||||
await discuss_plan(job(JobKind.DISCUSS), RecordingRun(), services)
|
||||
await discuss_plan(job(JobKind.DISCUSS), harness.run, harness.services)
|
||||
|
||||
assert str(error.value) == message
|
||||
assert opencode.resume_calls == []
|
||||
assert harness.opencode.resume_calls == []
|
||||
|
||||
|
||||
async def test_iterate_plan_revises_then_reuses_reviewer_session(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
existing = plan_workflow()
|
||||
services, repository, gitea, _, prompts, opencode = make_services(
|
||||
tmp_path,
|
||||
repository = FakeRepository(
|
||||
latest=existing,
|
||||
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=[
|
||||
PlanArtifact(plan_markdown="# Revised plan"),
|
||||
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 run.linked_workflows == [(existing.id, "iterating plan")]
|
||||
assert opencode.created_sessions == []
|
||||
assert [call["session_id"] for call in opencode.resume_calls] == [
|
||||
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 opencode.resume_calls] == [
|
||||
assert [call["result_type"] for call in harness.opencode.resume_calls] == [
|
||||
PlanArtifact,
|
||||
ReviewReport,
|
||||
]
|
||||
iterate_prompt = prompts.calls[0]
|
||||
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)")
|
||||
@@ -403,14 +330,19 @@ async def test_iterate_plan_rejects_missing_or_incompatible_plan(
|
||||
latest: Workflow | None,
|
||||
message: str,
|
||||
) -> 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:
|
||||
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 repository.saved_workflows == []
|
||||
assert opencode.resume_calls == []
|
||||
assert harness.opencode.resume_calls == []
|
||||
|
||||
|
||||
@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(
|
||||
tmp_path: Path, blocking_pull: PullRequestInfo
|
||||
) -> None:
|
||||
services, repository, _, _, _, opencode = make_services(
|
||||
tmp_path,
|
||||
repository = FakeRepository(
|
||||
latest=plan_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:
|
||||
await iterate_plan(job(JobKind.ITERATE_PLAN), RecordingRun(), services)
|
||||
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 opencode.resume_calls == []
|
||||
assert harness.opencode.resume_calls == []
|
||||
|
||||
Reference in New Issue
Block a user