diff --git a/.gitignore b/.gitignore index 174595c..007ab14 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,7 @@ venv/ .venv/ .python-version .pytest_cache +.env # Translations *.mo diff --git a/plane/__init__.py b/plane/__init__.py index 9a7d330..3dafd83 100644 --- a/plane/__init__.py +++ b/plane/__init__.py @@ -1,3 +1,4 @@ +from .api.agent_runs import AgentRuns from .api.cycles import Cycles from .api.labels import Labels from .api.modules import Modules @@ -24,6 +25,7 @@ "PlaneClient", "OAuthClient", "Configuration", + "AgentRuns", "WorkItems", "WorkItemTypes", "WorkItemProperties", diff --git a/plane/api/agent_runs/__init__.py b/plane/api/agent_runs/__init__.py new file mode 100644 index 0000000..94cf34d --- /dev/null +++ b/plane/api/agent_runs/__init__.py @@ -0,0 +1,4 @@ +from .base import AgentRuns + +__all__ = ["AgentRuns"] + diff --git a/plane/api/agent_runs/activities.py b/plane/api/agent_runs/activities.py new file mode 100644 index 0000000..acb6401 --- /dev/null +++ b/plane/api/agent_runs/activities.py @@ -0,0 +1,85 @@ +from collections.abc import Mapping +from typing import Any + +from ...models.agent_runs import ( + AgentRunActivity, + CreateAgentRunActivity, + PaginatedAgentRunActivityResponse, +) +from ..base_resource import BaseResource + + +class AgentRunActivities(BaseResource): + """Agent Run Activities API resource. + + Handles all agent run activity operations. + """ + + def __init__(self, config: Any) -> None: + super().__init__(config, "/workspaces/") + + def list( + self, + workspace_slug: str, + run_id: str, + params: Mapping[str, Any] | None = None, + ) -> PaginatedAgentRunActivityResponse: + """List activities for an agent run. + + Args: + workspace_slug: The workspace slug identifier + run_id: UUID of the agent run + params: Optional query parameters for pagination (per_page, cursor) + + Returns: + Paginated list of agent run activities + """ + response = self._get( + f"{workspace_slug}/runs/{run_id}/activities", + params=params, + ) + return PaginatedAgentRunActivityResponse.model_validate(response) + + def retrieve( + self, + workspace_slug: str, + run_id: str, + activity_id: str, + ) -> AgentRunActivity: + """Retrieve a specific agent run activity by ID. + + Args: + workspace_slug: The workspace slug identifier + run_id: UUID of the agent run + activity_id: UUID of the activity + + Returns: + The agent run activity + """ + response = self._get( + f"{workspace_slug}/runs/{run_id}/activities/{activity_id}" + ) + return AgentRunActivity.model_validate(response) + + def create( + self, + workspace_slug: str, + run_id: str, + data: CreateAgentRunActivity, + ) -> AgentRunActivity: + """Create a new agent run activity. + + Args: + workspace_slug: The workspace slug identifier + run_id: UUID of the agent run + data: The activity data to create + + Returns: + The created agent run activity + """ + response = self._post( + f"{workspace_slug}/runs/{run_id}/activities", + data.model_dump(exclude_none=True), + ) + return AgentRunActivity.model_validate(response) + diff --git a/plane/api/agent_runs/base.py b/plane/api/agent_runs/base.py new file mode 100644 index 0000000..bfb1ea6 --- /dev/null +++ b/plane/api/agent_runs/base.py @@ -0,0 +1,55 @@ +from typing import Any + +from ...models.agent_runs import AgentRun, CreateAgentRun +from ..base_resource import BaseResource +from .activities import AgentRunActivities + + +class AgentRuns(BaseResource): + """Agent Runs API resource. + + Handles all agent run operations. + """ + + def __init__(self, config: Any) -> None: + super().__init__(config, "/workspaces/") + + # Initialize sub-resources + self.activities = AgentRunActivities(config) + + def create( + self, + workspace_slug: str, + data: CreateAgentRun, + ) -> AgentRun: + """Create a new agent run. + + Args: + workspace_slug: The workspace slug identifier + data: The agent run data to create + + Returns: + The created agent run + """ + response = self._post( + f"{workspace_slug}/runs", + data.model_dump(exclude_none=True), + ) + return AgentRun.model_validate(response) + + def retrieve( + self, + workspace_slug: str, + run_id: str, + ) -> AgentRun: + """Retrieve an agent run by ID. + + Args: + workspace_slug: The workspace slug identifier + run_id: UUID of the agent run + + Returns: + The agent run + """ + response = self._get(f"{workspace_slug}/runs/{run_id}") + return AgentRun.model_validate(response) \ No newline at end of file diff --git a/plane/client/plane_client.py b/plane/client/plane_client.py index 21974d4..0162fa8 100644 --- a/plane/client/plane_client.py +++ b/plane/client/plane_client.py @@ -1,3 +1,4 @@ +from ..api.agent_runs import AgentRuns from ..api.customers import Customers from ..api.cycles import Cycles from ..api.epics import Epics @@ -53,4 +54,5 @@ def __init__( self.work_item_properties = WorkItemProperties(self.config) self.customers = Customers(self.config) self.intake = Intake(self.config) + self.agent_runs = AgentRuns(self.config) diff --git a/plane/models/agent_runs.py b/plane/models/agent_runs.py new file mode 100644 index 0000000..fee00ce --- /dev/null +++ b/plane/models/agent_runs.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +from enum import Enum +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict + + +class AgentRunStatus(str, Enum): + """Agent run status enum.""" + + CREATED = "created" + IN_PROGRESS = "in_progress" + AWAITING = "awaiting" + COMPLETED = "completed" + STOPPING = "stopping" + STOPPED = "stopped" + FAILED = "failed" + STALE = "stale" + + +class AgentRunType(str, Enum): + """Agent run type enum.""" + + COMMENT_THREAD = "comment_thread" + + +class AgentRunActivitySignal(str, Enum): + """Agent run activity signal enum.""" + + AUTH_REQUEST = "auth_request" + CONTINUE = "continue" + SELECT = "select" + STOP = "stop" + + +class AgentRunActivityType(str, Enum): + """Agent run activity type enum.""" + + ACTION = "action" + ELICITATION = "elicitation" + ERROR = "error" + PROMPT = "prompt" + RESPONSE = "response" + THOUGHT = "thought" + + +class AgentRun(BaseModel): + """Agent Run model.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str + agent_user: str + comment: str | None = None + source_comment: str | None = None + creator: str + stopped_at: str | None = None + stopped_by: str | None = None + started_at: str + ended_at: str | None = None + external_link: str | None = None + issue: str | None = None + workspace: str + project: str | None = None + status: AgentRunStatus + error_metadata: dict[str, Any] | None = None + type: AgentRunType + created_at: str | None = None + updated_at: str | None = None + + +class CreateAgentRun(BaseModel): + """Create agent run request model.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + agent_slug: str + issue: str | None = None + project: str | None = None + comment: str | None = None + source_comment: str | None = None + external_link: str | None = None + type: AgentRunType | None = None + + +class AgentRunActivityActionContent(BaseModel): + """Agent run activity content for action type.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + type: Literal["action"] + action: str + parameters: dict[str, str] + + +class AgentRunActivityTextContent(BaseModel): + """Agent run activity content for non-action types.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + type: Literal["elicitation", "error", "prompt", "response", "thought"] + body: str + + +AgentRunActivityContent = AgentRunActivityActionContent | AgentRunActivityTextContent + + +class AgentRunActivity(BaseModel): + """Agent Run Activity model.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str + agent_run: str + content: AgentRunActivityContent + content_metadata: dict[str, Any] | None = None + ephemeral: bool + signal: AgentRunActivitySignal + signal_metadata: dict[str, Any] | None = None + comment: str | None = None + actor: str | None = None + type: AgentRunActivityType + project: str | None = None + workspace: str + created_at: str | None = None + updated_at: str | None = None + + +class CreateAgentRunActivity(BaseModel): + """Create agent run activity request model.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + content: AgentRunActivityContent + content_metadata: dict[str, Any] | None = None + signal: AgentRunActivitySignal | None = None + signal_metadata: dict[str, Any] | None = None + type: Literal["action", "elicitation", "error", "response", "thought"] + project: str | None = None + + +class PaginatedAgentRunActivityResponse(BaseModel): + """Paginated agent run activity response.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + results: list[AgentRunActivity] + next_cursor: str | None = None + prev_cursor: str | None = None + next_page_results: bool | None = None + prev_page_results: bool | None = None + count: int | None = None + total_pages: int | None = None + total_results: int | None = None + extra_stats: dict[str, Any] | None = None + diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index d7ea7c3..8395c8c 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -6,7 +6,9 @@ import pytest from plane.client import PlaneClient +from plane.models.agent_runs import AgentRun, CreateAgentRun from plane.models.projects import CreateProject, Project +from plane.models.work_items import CreateWorkItem, CreateWorkItemComment @pytest.fixture(scope="session") @@ -73,3 +75,31 @@ def project(client: PlaneClient, workspace_slug: str) -> Project: client.projects.delete(workspace_slug, project.id) except Exception: pass + + +@pytest.fixture(scope="session") +def agent_run(client: PlaneClient, workspace_slug: str, project: Project) -> AgentRun: + agent_slug = os.getenv("AGENT_SLUG") + if not agent_slug: + pytest.skip("AGENT_SLUG environment variable not set") + """Create a test agent run and yield it.""" + work_item = client.work_items.create( + workspace_slug, + project.id, + CreateWorkItem(name="Test Work Item"), + ) + comment = client.work_items.comments.create( + workspace_slug, + project.id, + work_item.id, + CreateWorkItemComment(comment_html="
This is a test comment
"), + ) + agent_run = client.agent_runs.create( + workspace_slug, + CreateAgentRun( + agent_slug=agent_slug, + project=project.id, + comment=comment.id, + ), + ) + yield agent_run diff --git a/tests/unit/test_agent_runs.py b/tests/unit/test_agent_runs.py new file mode 100644 index 0000000..75fde30 --- /dev/null +++ b/tests/unit/test_agent_runs.py @@ -0,0 +1,122 @@ +"""Unit tests for AgentRuns API resource (smoke tests with real HTTP requests).""" + +import os +import time + +import pytest + +from plane.client import PlaneClient +from plane.models.agent_runs import ( + AgentRun, + AgentRunActivity, + CreateAgentRunActivity, + PaginatedAgentRunActivityResponse, +) +from plane.models.projects import Project + + +@pytest.fixture(scope="module") +def agent_slug() -> str: + """Get agent slug from environment variable.""" + slug = os.getenv("AGENT_SLUG") + if not slug: + pytest.skip("AGENT_SLUG environment variable not set") + return slug + + +class TestAgentRunsAPI: + """Test AgentRuns API resource.""" + + def test_retrieve_agent_run( + self, + client: PlaneClient, + workspace_slug: str, + agent_run: AgentRun, + ) -> None: + """Test retrieving an agent run.""" + retrieved = client.agent_runs.retrieve(workspace_slug, agent_run.id) + assert retrieved is not None + assert retrieved.id == agent_run.id + assert retrieved.workspace == agent_run.workspace + assert retrieved.status is not None + + +class TestAgentRunActivitiesAPI: + """Test AgentRunActivities API resource.""" + + @pytest.fixture + def activity_data(self, project: Project) -> CreateAgentRunActivity: + """Create test activity data.""" + return CreateAgentRunActivity( + type="response", + content={"type": "response", "body": f"Test activity {int(time.time())}"}, + project=project.id, + ) + + @pytest.fixture + def activity( + self, + client: PlaneClient, + workspace_slug: str, + agent_run: AgentRun, + activity_data: CreateAgentRunActivity, + ) -> AgentRunActivity: + """Create a test activity and yield it.""" + activity = client.agent_runs.activities.create(workspace_slug, agent_run.id, activity_data) + yield activity + + def test_list_activities( + self, + client: PlaneClient, + workspace_slug: str, + agent_run: AgentRun, + ) -> None: + """Test listing activities for an agent run.""" + response = client.agent_runs.activities.list(workspace_slug, agent_run.id) + assert response is not None + assert isinstance(response, PaginatedAgentRunActivityResponse) + assert hasattr(response, "results") + assert isinstance(response.results, list) + + def test_list_activities_with_pagination( + self, + client: PlaneClient, + workspace_slug: str, + agent_run: AgentRun, + ) -> None: + """Test listing activities with pagination parameters.""" + response = client.agent_runs.activities.list( + workspace_slug, agent_run.id, params={"per_page": 10} + ) + assert response is not None + assert isinstance(response, PaginatedAgentRunActivityResponse) + assert hasattr(response, "results") + + def test_create_activity( + self, + client: PlaneClient, + workspace_slug: str, + agent_run: AgentRun, + activity_data: CreateAgentRunActivity, + ) -> None: + """Test creating an agent run activity.""" + activity = client.agent_runs.activities.create(workspace_slug, agent_run.id, activity_data) + assert activity is not None + assert activity.id is not None + assert activity.agent_run == agent_run.id + assert activity.type is not None + assert activity.content is not None + + def test_retrieve_activity( + self, + client: PlaneClient, + workspace_slug: str, + agent_run: AgentRun, + activity: AgentRunActivity, + ) -> None: + """Test retrieving a specific agent run activity.""" + retrieved = client.agent_runs.activities.retrieve(workspace_slug, agent_run.id, activity.id) + assert retrieved is not None + assert retrieved.id == activity.id + assert retrieved.agent_run == agent_run.id + assert retrieved.type == activity.type