From 220a33f3e9691b2c649906c7421142cc28653b11 Mon Sep 17 00:00:00 2001 From: Saurabhkmr98 Date: Thu, 11 Dec 2025 17:52:55 +0530 Subject: [PATCH 1/2] feat: add Agent Runs API and related models, activities, and tests --- .env | 3 + plane/__init__.py | 2 + plane/api/agent_runs/__init__.py | 4 + plane/api/agent_runs/activities.py | 85 +++++++++++++ plane/api/agent_runs/base.py | 73 +++++++++++ plane/client/plane_client.py | 2 + plane/models/agent_runs.py | 157 +++++++++++++++++++++++ tests/unit/test_agent_runs.py | 197 +++++++++++++++++++++++++++++ 8 files changed, 523 insertions(+) create mode 100644 .env create mode 100644 plane/api/agent_runs/__init__.py create mode 100644 plane/api/agent_runs/activities.py create mode 100644 plane/api/agent_runs/base.py create mode 100644 plane/models/agent_runs.py create mode 100644 tests/unit/test_agent_runs.py diff --git a/.env b/.env new file mode 100644 index 0000000..020b38e --- /dev/null +++ b/.env @@ -0,0 +1,3 @@ +PLANE_BASE_URL=http://localhost:8000 +PLANE_API_KEY=plane_api_97e535f1b6dd4d90b89eae0bb1da3105 +WORKSPACE_SLUG=oauth-enhance \ No newline at end of file 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..33c3a5a --- /dev/null +++ b/plane/api/agent_runs/base.py @@ -0,0 +1,73 @@ +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) + + def resume( + self, + workspace_slug: str, + run_id: str, + ) -> AgentRun: + """Resume an agent run. + + Args: + workspace_slug: The workspace slug identifier + run_id: UUID of the agent run + + Returns: + The resumed agent run + """ + response = self._post(f"{workspace_slug}/runs/{run_id}") + return AgentRun.model_validate(response) + 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/test_agent_runs.py b/tests/unit/test_agent_runs.py new file mode 100644 index 0000000..b6bac8f --- /dev/null +++ b/tests/unit/test_agent_runs.py @@ -0,0 +1,197 @@ +"""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, + AgentRunType, + CreateAgentRun, + 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.""" + + @pytest.fixture + def agent_run_data(self, agent_slug: str, project: Project) -> CreateAgentRun: + """Create test agent run data.""" + return CreateAgentRun( + agent_slug=agent_slug, + project=project.id, + type=AgentRunType.COMMENT_THREAD, + ) + + @pytest.fixture + def agent_run( + self, + client: PlaneClient, + workspace_slug: str, + agent_run_data: CreateAgentRun, + ) -> AgentRun: + """Create a test agent run and yield it.""" + agent_run = client.agent_runs.create(workspace_slug, agent_run_data) + yield agent_run + # Note: Agent runs typically don't have a delete endpoint + + def test_create_agent_run( + self, + client: PlaneClient, + workspace_slug: str, + agent_run_data: CreateAgentRun, + ) -> None: + """Test creating an agent run.""" + agent_run = client.agent_runs.create(workspace_slug, agent_run_data) + assert agent_run is not None + assert agent_run.id is not None + assert agent_run.workspace is not None + assert agent_run.status is not None + assert agent_run.type == AgentRunType.COMMENT_THREAD + + 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 + + def test_resume_agent_run( + self, + client: PlaneClient, + workspace_slug: str, + agent_run: AgentRun, + ) -> None: + """Test resuming an agent run.""" + resumed = client.agent_runs.resume(workspace_slug, agent_run.id) + assert resumed is not None + assert resumed.id == agent_run.id + + +class TestAgentRunActivitiesAPI: + """Test AgentRunActivities API resource.""" + + @pytest.fixture + def agent_run_data(self, agent_slug: str, project: Project) -> CreateAgentRun: + """Create test agent run data.""" + return CreateAgentRun( + agent_slug=agent_slug, + project=project.id, + type=AgentRunType.COMMENT_THREAD, + ) + + @pytest.fixture + def agent_run( + self, + client: PlaneClient, + workspace_slug: str, + agent_run_data: CreateAgentRun, + ) -> AgentRun: + """Create a test agent run and yield it.""" + agent_run = client.agent_runs.create(workspace_slug, agent_run_data) + yield agent_run + + @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 + From f2d7a125c8c1c7b1015dda0931f2fe5c0add2800 Mon Sep 17 00:00:00 2001 From: Saurabhkmr98 Date: Thu, 18 Dec 2025 17:27:05 +0530 Subject: [PATCH 2/2] fix agent run tests --- .env | 3 -- .gitignore | 1 + plane/api/agent_runs/base.py | 20 +-------- tests/unit/conftest.py | 30 +++++++++++++ tests/unit/test_agent_runs.py | 81 ++--------------------------------- 5 files changed, 35 insertions(+), 100 deletions(-) delete mode 100644 .env diff --git a/.env b/.env deleted file mode 100644 index 020b38e..0000000 --- a/.env +++ /dev/null @@ -1,3 +0,0 @@ -PLANE_BASE_URL=http://localhost:8000 -PLANE_API_KEY=plane_api_97e535f1b6dd4d90b89eae0bb1da3105 -WORKSPACE_SLUG=oauth-enhance \ No newline at end of file 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/api/agent_runs/base.py b/plane/api/agent_runs/base.py index 33c3a5a..bfb1ea6 100644 --- a/plane/api/agent_runs/base.py +++ b/plane/api/agent_runs/base.py @@ -52,22 +52,4 @@ def retrieve( The agent run """ response = self._get(f"{workspace_slug}/runs/{run_id}") - return AgentRun.model_validate(response) - - def resume( - self, - workspace_slug: str, - run_id: str, - ) -> AgentRun: - """Resume an agent run. - - Args: - workspace_slug: The workspace slug identifier - run_id: UUID of the agent run - - Returns: - The resumed agent run - """ - response = self._post(f"{workspace_slug}/runs/{run_id}") - return AgentRun.model_validate(response) - + return AgentRun.model_validate(response) \ No newline at end of file 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 index b6bac8f..75fde30 100644 --- a/tests/unit/test_agent_runs.py +++ b/tests/unit/test_agent_runs.py @@ -9,8 +9,6 @@ from plane.models.agent_runs import ( AgentRun, AgentRunActivity, - AgentRunType, - CreateAgentRun, CreateAgentRunActivity, PaginatedAgentRunActivityResponse, ) @@ -29,41 +27,6 @@ def agent_slug() -> str: class TestAgentRunsAPI: """Test AgentRuns API resource.""" - @pytest.fixture - def agent_run_data(self, agent_slug: str, project: Project) -> CreateAgentRun: - """Create test agent run data.""" - return CreateAgentRun( - agent_slug=agent_slug, - project=project.id, - type=AgentRunType.COMMENT_THREAD, - ) - - @pytest.fixture - def agent_run( - self, - client: PlaneClient, - workspace_slug: str, - agent_run_data: CreateAgentRun, - ) -> AgentRun: - """Create a test agent run and yield it.""" - agent_run = client.agent_runs.create(workspace_slug, agent_run_data) - yield agent_run - # Note: Agent runs typically don't have a delete endpoint - - def test_create_agent_run( - self, - client: PlaneClient, - workspace_slug: str, - agent_run_data: CreateAgentRun, - ) -> None: - """Test creating an agent run.""" - agent_run = client.agent_runs.create(workspace_slug, agent_run_data) - assert agent_run is not None - assert agent_run.id is not None - assert agent_run.workspace is not None - assert agent_run.status is not None - assert agent_run.type == AgentRunType.COMMENT_THREAD - def test_retrieve_agent_run( self, client: PlaneClient, @@ -77,41 +40,10 @@ def test_retrieve_agent_run( assert retrieved.workspace == agent_run.workspace assert retrieved.status is not None - def test_resume_agent_run( - self, - client: PlaneClient, - workspace_slug: str, - agent_run: AgentRun, - ) -> None: - """Test resuming an agent run.""" - resumed = client.agent_runs.resume(workspace_slug, agent_run.id) - assert resumed is not None - assert resumed.id == agent_run.id - class TestAgentRunActivitiesAPI: """Test AgentRunActivities API resource.""" - @pytest.fixture - def agent_run_data(self, agent_slug: str, project: Project) -> CreateAgentRun: - """Create test agent run data.""" - return CreateAgentRun( - agent_slug=agent_slug, - project=project.id, - type=AgentRunType.COMMENT_THREAD, - ) - - @pytest.fixture - def agent_run( - self, - client: PlaneClient, - workspace_slug: str, - agent_run_data: CreateAgentRun, - ) -> AgentRun: - """Create a test agent run and yield it.""" - agent_run = client.agent_runs.create(workspace_slug, agent_run_data) - yield agent_run - @pytest.fixture def activity_data(self, project: Project) -> CreateAgentRunActivity: """Create test activity data.""" @@ -130,9 +62,7 @@ def activity( activity_data: CreateAgentRunActivity, ) -> AgentRunActivity: """Create a test activity and yield it.""" - activity = client.agent_runs.activities.create( - workspace_slug, agent_run.id, activity_data - ) + activity = client.agent_runs.activities.create(workspace_slug, agent_run.id, activity_data) yield activity def test_list_activities( @@ -170,9 +100,7 @@ def test_create_activity( activity_data: CreateAgentRunActivity, ) -> None: """Test creating an agent run activity.""" - activity = client.agent_runs.activities.create( - workspace_slug, agent_run.id, activity_data - ) + 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 @@ -187,11 +115,8 @@ def test_retrieve_activity( activity: AgentRunActivity, ) -> None: """Test retrieving a specific agent run activity.""" - retrieved = client.agent_runs.activities.retrieve( - workspace_slug, agent_run.id, activity.id - ) + 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 -