From 7667dd3402c9b4cc3e37c5bd2b35ccc2d2cffe12 Mon Sep 17 00:00:00 2001 From: Surya Prashanth Date: Tue, 10 Nov 2026 19:13:18 +0530 Subject: [PATCH] [SILO-1834] feat: agent runs and run activities on the v2 client Adds the agent-run endpoints from plane-ee #11099 to client.v2: - client.v2.workspaces.agent_runs: list, iterate, retrieve, create, update (agent bot only) and stop (people only). No delete; the API has none. Rows come back as LoadedAgentRun, which reaches run.activities with no ids repeated; a loaded workspace reaches workspace.agent_runs. - agent_runs.activities: list, iterate, retrieve and create on one run's append-only log. - client.v2.workspaces.agent_run_activities: the workspace-wide feed (workspace_run_activities_list). It is attached to the workspace, the scope its URL is in, as release_tags is. - Work item comments: an agent_run_id field, expand=["agent_run"] typed as AgentRunLite, and the agent_run_id filter. - Models in plane.models.v2.agent_runs, typed from the golden. - Generated constants regenerated from the golden the SDK was built on (plane-ee a8a1a9f43be) plus only PR #11099's golden diff. The change is additions only. - Offline tests, tree rows, and a live suite that drives both principals (person api key, agent bot token). The live suite needs AGENT_SLUG and AGENT_BOT_TOKEN. - Version bumped to 0.3.4. --- CLAUDE.md | 28 +- README.md | 47 +- plane/api/v2/__init__.py | 8 +- plane/api/v2/_generated/constants.py | 549 +++++++++++++++++++++++- plane/api/v2/_loaded/agent_run.py | 40 ++ plane/api/v2/_loaded/workspace.py | 29 +- plane/api/v2/agent_runs/__init__.py | 192 +++++++++ plane/api/v2/agent_runs/activities.py | 216 ++++++++++ plane/api/v2/workspaces.py | 3 + plane/models/v2/__init__.py | 22 + plane/models/v2/agent_runs.py | 159 +++++++ plane/models/v2/work_items.py | 7 + pyproject.toml | 2 +- tests/v2/integration/conftest.py | 34 +- tests/v2/integration/test_agent_runs.py | 239 +++++++++++ tests/v2/test_agent_runs_resource.py | 374 ++++++++++++++++ tests/v2/test_tree.py | 18 + tests/v2/test_work_items_resource.py | 57 +++ 18 files changed, 1994 insertions(+), 30 deletions(-) create mode 100644 plane/api/v2/_loaded/agent_run.py create mode 100644 plane/api/v2/agent_runs/__init__.py create mode 100644 plane/api/v2/agent_runs/activities.py create mode 100644 plane/models/v2/agent_runs.py create mode 100644 tests/v2/integration/test_agent_runs.py create mode 100644 tests/v2/test_agent_runs_resource.py diff --git a/CLAUDE.md b/CLAUDE.md index 1a1caef..14369db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,10 @@ Tests make real HTTP requests (no mocking). Set these before running: - `PLANE_BASE_URL` — API base URL - `PLANE_API_KEY` or `PLANE_ACCESS_TOKEN` — authentication (exactly one) - `WORKSPACE_SLUG` — test workspace -- `AGENT_SLUG` — (optional) needed only for agent run tests +- `AGENT_SLUG` — (optional) needed only for agent run tests (v1 and v2): the slug of an + agent app installed in the workspace +- `AGENT_BOT_TOKEN` — (optional) an access token of that app's bot; the v2 agent-run tests + that act as the agent (`update`, the agent's output activities) skip without it ## Architecture @@ -78,7 +81,7 @@ PlaneClient - `plane/client/` — `PlaneClient` (API key / access token auth) and `OAuthClient` (OAuth 2.0 flows). - `plane/errors/` — `PlaneError` → `HttpError`, `ConfigurationError`. - `plane/config.py` — `Configuration` and `RetryConfig` dataclasses. -- `plane/api/v2/` — the v2 surface (`client.v2`). **Migration complete**: all 90 +- `plane/api/v2/` — the v2 surface (`client.v2`). **Migration complete**: all 93 `V2Resource` subclasses in the package (`tests/v2/tree_walk.py`'s `all_resource_classes()`) are on the flat shape and swept by the rule tests. `tests/v2/tree_walk.py`'s `UNMIGRATED_RESOURCES` — the opt-out list the sweeps @@ -87,7 +90,7 @@ PlaneClient never grow, so it cannot silently regain a member. Count resources, not grouping nodes: `wiki` and `group_sync` hold no `V2Resource` base, `path` or `operations` of their own — they only group children (`wiki.pages`, - `wiki.collections`, `group_sync.config`) — so neither is in the 90. + `wiki.collections`, `group_sync.config`) — so neither is in the 93. The whole project band is wired onto `client.v2.workspaces.projects`: `states`, `labels`, `work_items`, `cycles`, `milestones`, `modules`, @@ -95,17 +98,17 @@ PlaneClient `work_item_templates`, `worklogs`, `pages`, `automations`, `work_item_properties`, `work_item_types` and `workflows` — nineteen children, and a fetched project reaches every one of them that is itself - navigable (`project.cycles.list()`, `project.permissions.me()`). Nineteen of - the 90 classes are navigable — `workspaces`, `projects`, `work_items`, + navigable (`project.cycles.list()`, `project.permissions.me()`). Twenty of + the 93 classes are navigable — `workspaces`, `projects`, `work_items`, `cycles`, `milestones`, `modules`, `estimates`, `webhooks`, `collections`, `customers`, `initiatives`, `releases`, `work_item_types`, - `work_item_properties`, `automations` and `workflows` (three of those — + `work_item_properties`, `automations`, `workflows` and `agent_runs` (three of those — automations, work item types and work item properties — each have a separate project-scoped and workspace-scoped resource class, each independently - navigable, which is where 16 families become 19 classes) — a fetched row + navigable, which is where 17 families become 20 classes) — a fetched row reaches its child with no ids repeated. `workspaces` is the root of that set and the last to join it: `client.v2.workspaces.retrieve("acme")` answers a - `LoadedWorkspace` reaching all 25 workspace-scoped families + `LoadedWorkspace` reaching all 27 workspace-scoped families (`workspace.projects.list()`), and its children address it by `slug`, never the UUID `id`, which that path segment does not accept. `estimates`' child is `estimate_points`, not `points` — `Estimate.points` is itself an API field, @@ -162,8 +165,8 @@ PlaneClient - **Loaded rows.** A resource with children (`projects`, `work_items`, `cycles`, `milestones`, `modules`, `estimates`, `webhooks`, `collections`, `customers`, `initiatives`, `releases`, `work_item_types`, - `work_item_properties`, `automations`, `workflows`, `workspaces` — 19 of the - 90 classes) returns a `Loaded` row from `retrieve`/`list`/`iterate`, not a bare + `work_item_properties`, `automations`, `workflows`, `workspaces`, `agent_runs` — 20 of + the 93 classes) returns a `Loaded` row from `retrieve`/`list`/`iterate`, not a bare pydantic model: it carries its own data and reaches its own children with none of the ids repeated (`project.states.list()`, `work_item.comments.list()`, `cycle.work_items.add(["w1"])`). @@ -245,9 +248,10 @@ PlaneClient per `Loaded` subclass — `project.py`, `work_item.py`, `cycle.py`, `milestone.py`, `module.py`, `estimate.py`, `webhook.py`, `collection.py`, `customer.py`, `initiative.py`, `release.py`, `work_item_type.py`, - `work_item_property.py`, `automation.py`, `workflow.py`, `workspace.py`; copy + `work_item_property.py`, `automation.py`, `workflow.py`, `workspace.py`, + `agent_run.py`; copy whichever is closest in shape (single bridge-only child vs. several plain-CRUD - children). `workspace.py` is the `bind1` exemplar and the widest, at 25 + children). `workspace.py` is the `bind1` exemplar and the widest, at 27 children; the two grouping nodes (`wiki`, `group_sync`) are deliberately not among them — neither holds a `V2Resource` base, so neither is a child a row can bind, and they are reached from the namespace instead. diff --git a/README.md b/README.md index 536a156..07831d4 100644 --- a/README.md +++ b/README.md @@ -200,15 +200,16 @@ work_items = client.work_items.list( `client.v2` reaches the v2 surface. v1 resources on the client are unchanged. -The v2 surface is complete: every one of the 90 `V2Resource` subclasses in the +The v2 surface is complete: every one of the 93 `V2Resource` subclasses in the package (`tests/v2/tree_walk.py`'s `all_resource_classes()`, the enumeration the test suite itself sweeps) is on the flat shape described below and reachable through `client.v2`. Counting resources means not counting grouping nodes: `wiki` and `group_sync` hold no `V2Resource` base, `path` or `operations` of their own — they only group children (`wiki.pages`, `wiki.collections`, -`group_sync.config`) — and are outside the 90. +`group_sync.config`) — and are outside the 93. -Wired directly on `client.v2.workspaces`: `artifacts`, `assets`, `audit_logs`, +Wired directly on `client.v2.workspaces`: `agent_runs` (with `.activities`), +`agent_run_activities`, `artifacts`, `assets`, `audit_logs`, `automations`, `customer_properties`, `customers`, `features`, `initiatives`, `invitations`, `members`, `permission_schemes`, `permissions`, `projects`, `releases` (`.labels`, `.tags`, `.comments`, `.links`, `.changelog`, @@ -276,8 +277,8 @@ client.v2.workspaces.projects.states.list(slug="acme", project="ENG") A resource with children (`projects`, `work_items`, `cycles`, `milestones`, `modules`, `estimates`, `webhooks`, `collections`, `customers`, `initiatives`, -`releases`, `work_item_types`, `work_item_properties`, `automations` and -`workflows` — 18 of the 90 classes, some families having a separate +`releases`, `work_item_types`, `work_item_properties`, `automations`, +`workflows` and `agent_runs` — 20 of the 93 classes, some families having a separate project-scoped and workspace-scoped resource, each independently navigable) doesn't just hand back a bare pydantic model from `retrieve`/`list`/`iterate` — it hands back a row that carries its own data *and* already knows where it @@ -310,7 +311,8 @@ log; `collections` via `.members`/`.pages`; `customers` via `.requests`, `.changelog`, `.work_items`; `work_item_types` via `.properties`; `work_item_properties` via `.property_options` (plus `.contexts` on the workspace-scoped resource only); `automations` via `.edges`, `.nodes`, -`.activities`; and `workflows` via `.states`, `.transitions`. A work item +`.activities`; `workflows` via `.states`, `.transitions`; and `agent_runs` via +`.activities`, the run's log. A work item itself reaches all seven of its own children this way — `comments`, `attachments`, `links`, `worklogs`, `activities`, `relations`, `dependencies`. @@ -369,7 +371,7 @@ scripts to prove it, rather than trusting it by inspection. ### The five rules the tests enforce Five properties of the surface are each enforced by a sweep in `tests/v2/`, over -every one of the 90 resource classes (`tests/v2/tree_walk.py`'s +every one of the 93 resource classes (`tests/v2/tree_walk.py`'s `all_resource_classes()`) rather than a hand-picked subset — so a newly added resource is covered the moment it exists, with nothing to remember to add it to: @@ -455,6 +457,37 @@ client.v2.workspaces.group_sync.project_mappings.list("acme") client.v2.workspaces.group_sync.workspace_mappings.list("acme") ``` +### Agent runs + +`agent_runs` is the work an agent (an installed app's bot) does in a workspace. +A person starts a run on a top-level work item comment, replies to it with a +`prompt` and can `stop` it; only the agent itself — a client built on the app +bot's access token — may `update` the run or post its output activities. A run +is settled, never deleted, so there is no `delete`. + +```python +from plane.models.v2 import CreateAgentRun, CreateAgentRunActivity, UpdateAgentRun + +workspace = client.v2.workspaces.retrieve("acme") +run = workspace.agent_runs.create(CreateAgentRun(agent_slug="poem-agent", comment_id=comment.id)) +run.activities.create( + CreateAgentRunActivity(type="prompt", content={"type": "prompt", "body": "

Make it rhyme

"}) +) +workspace.agent_runs.stop(run.id) # -> status "stopping"; 409 if it already ended + +# As the agent (PlaneClient(base_url=..., access_token=)): +agent.v2.workspaces.agent_runs.update("acme", run.id, UpdateAgentRun(status="completed")) +``` + +To follow runs, poll `agent_runs.list(slug, paginate="cursor", +updated_at__gte=...)`, or follow one feed of every run's activity with +`agent_run_activities.list(slug, paginate="cursor", created_at__gte=...)`. Both +hold back rows written in the last 3 seconds, so dedupe by `id`. A bot token +sees only its own runs; a person sees a run only if they can see its work item. +A work item comment that started a run carries `agent_run_id`, filters on +`comments.list(..., agent_run_id=...)` and inlines the run with +`expand=["agent_run"]`. + ### Errors Errors from `client.v2` calls raise `PlaneAPIError` (RFC 9457 problem detail — diff --git a/plane/api/v2/__init__.py b/plane/api/v2/__init__.py index b584529..6316535 100644 --- a/plane/api/v2/__init__.py +++ b/plane/api/v2/__init__.py @@ -4,7 +4,7 @@ `users`/`user_assets` have no workspace in their path and sit on `V2Namespace` directly. -The 19 `Loaded*` row types are re-exported here too. They are the declared return +The 20 `Loaded*` row types are re-exported here too. They are the declared return type of every `retrieve`/`list`/`iterate`/`find_by_*`/verb on a navigable resource, so a caller must be able to *name* one -- to annotate a variable, a helper's parameter or a test fixture -- without importing out of the private `_loaded` @@ -22,6 +22,7 @@ PlaneAPIError, ) from ._kernel.transport import V2Transport +from ._loaded.agent_run import LoadedAgentRun from ._loaded.automation import LoadedProjectAutomation, LoadedWorkspaceAutomation from ._loaded.collection import LoadedCollection from ._loaded.customer import LoadedCustomer @@ -38,6 +39,7 @@ from ._loaded.work_item_type import LoadedWorkItemType, LoadedWorkspaceWorkItemType from ._loaded.workflow import LoadedWorkflow from ._loaded.workspace import LoadedWorkspace +from .agent_runs import AgentRunActivities, AgentRuns, WorkspaceAgentRunActivities from .artifacts import Artifacts from .assets import UserAssets, WorkspaceAssets from .audit_logs import AuditLogs @@ -90,6 +92,8 @@ def __init__(self, config: Configuration) -> None: __all__ = [ + "AgentRunActivities", + "AgentRuns", "Artifacts", "AuditLogs", "ProjectAutomations", @@ -108,6 +112,7 @@ def __init__(self, config: Configuration) -> None: "Intakes", "Invitations", "Labels", + "LoadedAgentRun", "LoadedCollection", "LoadedCustomer", "LoadedCycle", @@ -151,6 +156,7 @@ def __init__(self, config: Configuration) -> None: "Users", "V2Namespace", "Webhooks", + "WorkspaceAgentRunActivities", "Wiki", "WikiPages", "WorkItemProperties", diff --git a/plane/api/v2/_generated/constants.py b/plane/api/v2/_generated/constants.py index e2cd942..1b24d1c 100644 --- a/plane/api/v2/_generated/constants.py +++ b/plane/api/v2/_generated/constants.py @@ -245,6 +245,14 @@ "releases_work_items", "roles_list", "roles_retrieve", + "run_activities_create", + "run_activities_list", + "run_activities_retrieve", + "runs_create", + "runs_list", + "runs_partial_update", + "runs_retrieve", + "runs_stop", "states_bulk_create", "states_bulk_delete", "states_bulk_update", @@ -382,6 +390,7 @@ "workspace_pages_list", "workspace_pages_partial_update", "workspace_pages_retrieve", + "workspace_run_activities_list", "workspace_views_create", "workspace_views_destroy", "workspace_views_list", @@ -709,6 +718,7 @@ [ "access", "actor_id", + "agent_run_id", "all", "comment_html", "comment_stripped", @@ -725,6 +735,7 @@ [ "access", "actor_id", + "agent_run_id", "all", "comment_html", "comment_stripped", @@ -741,6 +752,7 @@ [ "access", "actor_id", + "agent_run_id", "all", "comment_html", "comment_stripped", @@ -757,6 +769,7 @@ [ "access", "actor_id", + "agent_run_id", "all", "comment_html", "comment_stripped", @@ -773,6 +786,7 @@ [ "access", "actor_id", + "agent_run_id", "all", "comment_html", "comment_stripped", @@ -3234,6 +3248,226 @@ "roles_retrieve": frozenset( ["all", "description", "id", "is_system", "level", "name", "namespace", "slug", "status"] ), + "run_activities_create": frozenset( + [ + "actor_id", + "agent_run_id", + "all", + "comment_id", + "content", + "content_metadata", + "created_at", + "created_by_id", + "ephemeral", + "id", + "project_id", + "signal", + "signal_metadata", + "type", + "updated_at", + "updated_by_id", + ] + ), + "run_activities_list": frozenset( + [ + "actor_id", + "agent_run_id", + "all", + "comment_id", + "content", + "content_metadata", + "created_at", + "created_by_id", + "ephemeral", + "id", + "project_id", + "signal", + "signal_metadata", + "type", + "updated_at", + "updated_by_id", + ] + ), + "run_activities_retrieve": frozenset( + [ + "actor_id", + "agent_run_id", + "all", + "comment_id", + "content", + "content_metadata", + "created_at", + "created_by_id", + "ephemeral", + "id", + "project_id", + "signal", + "signal_metadata", + "type", + "updated_at", + "updated_by_id", + ] + ), + "runs_create": frozenset( + [ + "agent_user_id", + "all", + "comment_id", + "created_at", + "created_by_id", + "creator_id", + "credit_usage", + "ended_at", + "error_metadata", + "external_link", + "external_trigger_id", + "external_trigger_source", + "id", + "native_agent_id", + "project_id", + "source_comment_id", + "started_at", + "status", + "stopped_at", + "stopped_by_id", + "summary", + "title", + "token_usage", + "trigger_type", + "type", + "updated_at", + "updated_by_id", + "work_item_id", + ] + ), + "runs_list": frozenset( + [ + "agent_user_id", + "all", + "comment_id", + "created_at", + "created_by_id", + "creator_id", + "credit_usage", + "ended_at", + "error_metadata", + "external_link", + "external_trigger_id", + "external_trigger_source", + "id", + "native_agent_id", + "project_id", + "source_comment_id", + "started_at", + "status", + "stopped_at", + "stopped_by_id", + "summary", + "title", + "token_usage", + "trigger_type", + "type", + "updated_at", + "updated_by_id", + "work_item_id", + ] + ), + "runs_partial_update": frozenset( + [ + "agent_user_id", + "all", + "comment_id", + "created_at", + "created_by_id", + "creator_id", + "credit_usage", + "ended_at", + "error_metadata", + "external_link", + "external_trigger_id", + "external_trigger_source", + "id", + "native_agent_id", + "project_id", + "source_comment_id", + "started_at", + "status", + "stopped_at", + "stopped_by_id", + "summary", + "title", + "token_usage", + "trigger_type", + "type", + "updated_at", + "updated_by_id", + "work_item_id", + ] + ), + "runs_retrieve": frozenset( + [ + "agent_user_id", + "all", + "comment_id", + "created_at", + "created_by_id", + "creator_id", + "credit_usage", + "ended_at", + "error_metadata", + "external_link", + "external_trigger_id", + "external_trigger_source", + "id", + "native_agent_id", + "project_id", + "source_comment_id", + "started_at", + "status", + "stopped_at", + "stopped_by_id", + "summary", + "title", + "token_usage", + "trigger_type", + "type", + "updated_at", + "updated_by_id", + "work_item_id", + ] + ), + "runs_stop": frozenset( + [ + "agent_user_id", + "all", + "comment_id", + "created_at", + "created_by_id", + "creator_id", + "credit_usage", + "ended_at", + "error_metadata", + "external_link", + "external_trigger_id", + "external_trigger_source", + "id", + "native_agent_id", + "project_id", + "source_comment_id", + "started_at", + "status", + "stopped_at", + "stopped_by_id", + "summary", + "title", + "token_usage", + "trigger_type", + "type", + "updated_at", + "updated_by_id", + "work_item_id", + ] + ), "states_create": frozenset( [ "all", @@ -3693,6 +3927,7 @@ [ "access", "actor_id", + "agent_run_id", "all", "comment_html", "comment_stripped", @@ -5081,6 +5316,26 @@ "view_props", ] ), + "workspace_run_activities_list": frozenset( + [ + "actor_id", + "agent_run_id", + "all", + "comment_id", + "content", + "content_metadata", + "created_at", + "created_by_id", + "ephemeral", + "id", + "project_id", + "signal", + "signal_metadata", + "type", + "updated_at", + "updated_by_id", + ] + ), "workspace_views_create": frozenset( [ "access", @@ -5639,6 +5894,8 @@ "roles_list": frozenset( ["-id", "-level", "-name", "-namespace", "id", "level", "name", "namespace"] ), + "run_activities_list": frozenset(["-created_at", "-id", "created_at", "id"]), + "runs_list": frozenset(["-created_at", "-id", "-updated_at", "created_at", "id", "updated_at"]), "states_list": frozenset(["-created_at", "-id", "-sequence", "created_at", "id", "sequence"]), "stickies_list": frozenset( ["-created_at", "-id", "-sort_order", "created_at", "id", "sort_order"] @@ -5696,6 +5953,7 @@ "workspace_pages_list": frozenset( ["-created_at", "-id", "-name", "-updated_at", "created_at", "id", "name", "updated_at"] ), + "workspace_run_activities_list": frozenset(["-created_at", "-id", "created_at", "id"]), "workspace_views_list": frozenset( ["-created_at", "-id", "-sort_order", "created_at", "id", "sort_order"] ), @@ -5736,11 +5994,11 @@ "activities_list": frozenset(["actor"]), "activities_retrieve": frozenset(["actor"]), "collections_members_list": frozenset(["member"]), - "comments_create": frozenset(["actor"]), - "comments_destroy": frozenset(["actor"]), - "comments_list": frozenset(["actor"]), - "comments_partial_update": frozenset(["actor"]), - "comments_retrieve": frozenset(["actor"]), + "comments_create": frozenset(["actor", "agent_run"]), + "comments_destroy": frozenset(["actor", "agent_run"]), + "comments_list": frozenset(["actor", "agent_run"]), + "comments_partial_update": frozenset(["actor", "agent_run"]), + "comments_retrieve": frozenset(["actor", "agent_run"]), "cycles_create": frozenset(["owned_by"]), "cycles_destroy": frozenset(["owned_by"]), "cycles_list": frozenset(["owned_by"]), @@ -5795,12 +6053,22 @@ "releases_list": frozenset(["lead", "tag"]), "releases_partial_update": frozenset(["lead", "tag"]), "releases_retrieve": frozenset(["lead", "tag"]), + "run_activities_create": frozenset(["actor"]), + "run_activities_list": frozenset(["actor"]), + "run_activities_retrieve": frozenset(["actor"]), + "runs_create": frozenset(["agent_user", "comment", "creator", "source_comment", "work_item"]), + "runs_list": frozenset(["agent_user", "comment", "creator", "source_comment", "work_item"]), + "runs_partial_update": frozenset( + ["agent_user", "comment", "creator", "source_comment", "work_item"] + ), + "runs_retrieve": frozenset(["agent_user", "comment", "creator", "source_comment", "work_item"]), + "runs_stop": frozenset(["agent_user", "comment", "creator", "source_comment", "work_item"]), "teamspaces_create": frozenset(["lead"]), "teamspaces_destroy": frozenset(["lead"]), "teamspaces_list": frozenset(["lead"]), "teamspaces_partial_update": frozenset(["lead"]), "teamspaces_retrieve": frozenset(["lead"]), - "work_item_comments_upsert": frozenset(["actor"]), + "work_item_comments_upsert": frozenset(["actor", "agent_run"]), "work_items_archive": frozenset( ["assignees", "cycle", "labels", "modules", "parent", "state", "type"] ), @@ -5842,6 +6110,7 @@ "workspace_pages_list": frozenset(["owned_by", "parent"]), "workspace_pages_partial_update": frozenset(["owned_by", "parent"]), "workspace_pages_retrieve": frozenset(["owned_by", "parent"]), + "workspace_run_activities_list": frozenset(["actor"]), "workspace_views_create": frozenset(["owned_by"]), "workspace_views_destroy": frozenset(["owned_by"]), "workspace_views_list": frozenset(["owned_by"]), @@ -5891,6 +6160,8 @@ "release_tags_list": frozenset(["count", "offset", "paginate", "per_page"]), "releases_list": frozenset(["count", "offset", "paginate", "per_page"]), "roles_list": frozenset(["count", "offset", "paginate", "per_page"]), + "run_activities_list": frozenset(["count", "offset", "paginate", "per_page"]), + "runs_list": frozenset(["count", "offset", "paginate", "per_page"]), "states_list": frozenset(["count", "offset", "paginate", "per_page"]), "stickies_list": frozenset(["count", "offset", "paginate", "per_page"]), "teamspaces_list": frozenset(["count", "offset", "paginate", "per_page"]), @@ -5914,6 +6185,7 @@ "workspace_automations_list": frozenset(["count", "offset", "paginate", "per_page"]), "workspace_members_list": frozenset(["count", "offset", "paginate", "per_page"]), "workspace_pages_list": frozenset(["count", "offset", "paginate", "per_page"]), + "workspace_run_activities_list": frozenset(["count", "offset", "paginate", "per_page"]), "workspace_views_list": frozenset(["count", "offset", "paginate", "per_page"]), "workspace_work_item_properties_list": frozenset(["count", "offset", "paginate", "per_page"]), "workspace_work_item_property_options_list": frozenset( @@ -6201,6 +6473,7 @@ CommentsCreateField = Literal[ "access", "actor_id", + "agent_run_id", "all", "comment_html", "comment_stripped", @@ -6215,6 +6488,7 @@ CommentsDestroyField = Literal[ "access", "actor_id", + "agent_run_id", "all", "comment_html", "comment_stripped", @@ -6229,6 +6503,7 @@ CommentsListField = Literal[ "access", "actor_id", + "agent_run_id", "all", "comment_html", "comment_stripped", @@ -6243,6 +6518,7 @@ CommentsPartialUpdateField = Literal[ "access", "actor_id", + "agent_run_id", "all", "comment_html", "comment_stripped", @@ -6257,6 +6533,7 @@ CommentsRetrieveField = Literal[ "access", "actor_id", + "agent_run_id", "all", "comment_html", "comment_stripped", @@ -8428,6 +8705,210 @@ RolesRetrieveField = Literal[ "all", "description", "id", "is_system", "level", "name", "namespace", "slug", "status" ] +RunActivitiesCreateField = Literal[ + "actor_id", + "agent_run_id", + "all", + "comment_id", + "content", + "content_metadata", + "created_at", + "created_by_id", + "ephemeral", + "id", + "project_id", + "signal", + "signal_metadata", + "type", + "updated_at", + "updated_by_id", +] +RunActivitiesListField = Literal[ + "actor_id", + "agent_run_id", + "all", + "comment_id", + "content", + "content_metadata", + "created_at", + "created_by_id", + "ephemeral", + "id", + "project_id", + "signal", + "signal_metadata", + "type", + "updated_at", + "updated_by_id", +] +RunActivitiesRetrieveField = Literal[ + "actor_id", + "agent_run_id", + "all", + "comment_id", + "content", + "content_metadata", + "created_at", + "created_by_id", + "ephemeral", + "id", + "project_id", + "signal", + "signal_metadata", + "type", + "updated_at", + "updated_by_id", +] +RunsCreateField = Literal[ + "agent_user_id", + "all", + "comment_id", + "created_at", + "created_by_id", + "creator_id", + "credit_usage", + "ended_at", + "error_metadata", + "external_link", + "external_trigger_id", + "external_trigger_source", + "id", + "native_agent_id", + "project_id", + "source_comment_id", + "started_at", + "status", + "stopped_at", + "stopped_by_id", + "summary", + "title", + "token_usage", + "trigger_type", + "type", + "updated_at", + "updated_by_id", + "work_item_id", +] +RunsListField = Literal[ + "agent_user_id", + "all", + "comment_id", + "created_at", + "created_by_id", + "creator_id", + "credit_usage", + "ended_at", + "error_metadata", + "external_link", + "external_trigger_id", + "external_trigger_source", + "id", + "native_agent_id", + "project_id", + "source_comment_id", + "started_at", + "status", + "stopped_at", + "stopped_by_id", + "summary", + "title", + "token_usage", + "trigger_type", + "type", + "updated_at", + "updated_by_id", + "work_item_id", +] +RunsPartialUpdateField = Literal[ + "agent_user_id", + "all", + "comment_id", + "created_at", + "created_by_id", + "creator_id", + "credit_usage", + "ended_at", + "error_metadata", + "external_link", + "external_trigger_id", + "external_trigger_source", + "id", + "native_agent_id", + "project_id", + "source_comment_id", + "started_at", + "status", + "stopped_at", + "stopped_by_id", + "summary", + "title", + "token_usage", + "trigger_type", + "type", + "updated_at", + "updated_by_id", + "work_item_id", +] +RunsRetrieveField = Literal[ + "agent_user_id", + "all", + "comment_id", + "created_at", + "created_by_id", + "creator_id", + "credit_usage", + "ended_at", + "error_metadata", + "external_link", + "external_trigger_id", + "external_trigger_source", + "id", + "native_agent_id", + "project_id", + "source_comment_id", + "started_at", + "status", + "stopped_at", + "stopped_by_id", + "summary", + "title", + "token_usage", + "trigger_type", + "type", + "updated_at", + "updated_by_id", + "work_item_id", +] +RunsStopField = Literal[ + "agent_user_id", + "all", + "comment_id", + "created_at", + "created_by_id", + "creator_id", + "credit_usage", + "ended_at", + "error_metadata", + "external_link", + "external_trigger_id", + "external_trigger_source", + "id", + "native_agent_id", + "project_id", + "source_comment_id", + "started_at", + "status", + "stopped_at", + "stopped_by_id", + "summary", + "title", + "token_usage", + "trigger_type", + "type", + "updated_at", + "updated_by_id", + "work_item_id", +] StatesCreateField = Literal[ "all", "color", @@ -8828,6 +9309,7 @@ WorkItemCommentsUpsertField = Literal[ "access", "actor_id", + "agent_run_id", "all", "comment_html", "comment_stripped", @@ -10063,6 +10545,24 @@ "sort_order", "view_props", ] +WorkspaceRunActivitiesListField = Literal[ + "actor_id", + "agent_run_id", + "all", + "comment_id", + "content", + "content_metadata", + "created_at", + "created_by_id", + "ephemeral", + "id", + "project_id", + "signal", + "signal_metadata", + "type", + "updated_at", + "updated_by_id", +] WorkspaceViewsCreateField = Literal[ "access", "all", @@ -10552,6 +11052,8 @@ RolesListOrderBy = Literal[ "-id", "-level", "-name", "-namespace", "id", "level", "name", "namespace" ] +RunActivitiesListOrderBy = Literal["-created_at", "-id", "created_at", "id"] +RunsListOrderBy = Literal["-created_at", "-id", "-updated_at", "created_at", "id", "updated_at"] StatesListOrderBy = Literal["-created_at", "-id", "-sequence", "created_at", "id", "sequence"] StickiesListOrderBy = Literal["-created_at", "-id", "-sort_order", "created_at", "id", "sort_order"] TeamspacesListOrderBy = Literal["-created_at", "-id", "created_at", "id"] @@ -10603,6 +11105,7 @@ WorkspacePagesListOrderBy = Literal[ "-created_at", "-id", "-name", "-updated_at", "created_at", "id", "name", "updated_at" ] +WorkspaceRunActivitiesListOrderBy = Literal["-created_at", "-id", "created_at", "id"] WorkspaceViewsListOrderBy = Literal[ "-created_at", "-id", "-sort_order", "created_at", "id", "sort_order" ] @@ -10668,6 +11171,7 @@ class AuditLogsListFilters(TypedDict, total=False): class CommentsListFilters(TypedDict, total=False): access: str + agent_run_id: str external_id: str external_source: str search: str @@ -10926,6 +11430,28 @@ class RolesListFilters(TypedDict, total=False): slug: str +class RunActivitiesListFilters(TypedDict, total=False): + actor_id: str + agent_run_id: str + comment_id: str + created_at__gte: str + ephemeral: bool + signal: str + type: str + type__in: Sequence[str] + + +class RunsListFilters(TypedDict, total=False): + agent_user_id: str + project_id: str + status: str + status__in: Sequence[str] + trigger_type: str + type: str + updated_at__gte: str + work_item_id: str + + class StatesListFilters(TypedDict, total=False): external_id: str external_source: str @@ -11087,6 +11613,17 @@ class WorkspacePagesListFilters(TypedDict, total=False): type: str +class WorkspaceRunActivitiesListFilters(TypedDict, total=False): + actor_id: str + agent_run_id: str + comment_id: str + created_at__gte: str + ephemeral: bool + signal: str + type: str + type__in: Sequence[str] + + class WorkspaceViewsListFilters(TypedDict, total=False): access: int is_locked: bool diff --git a/plane/api/v2/_loaded/agent_run.py b/plane/api/v2/_loaded/agent_run.py new file mode 100644 index 0000000..d1d8e06 --- /dev/null +++ b/plane/api/v2/_loaded/agent_run.py @@ -0,0 +1,40 @@ +"""A fetched agent run row that is also the place its activities live.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from ....models.v2.agent_runs import AgentRun +from .._kernel.loaded import Loaded, Owned, bind2 + +if TYPE_CHECKING: + from ..agent_runs import AgentRunActivities, AgentRuns + + # Typed view on `Owned`: `AgentRunActivities`' own methods with `slug` and `run` + # already supplied -- `bind2`, because two path ids are bound. Evaluated only by a + # type checker -- at runtime the property returns a plain `Owned`. + + class _OwnedAgentRunActivities(Owned["AgentRunActivities"]): + list = staticmethod(bind2(AgentRunActivities.list)) + iterate = staticmethod(bind2(AgentRunActivities.iterate)) + retrieve = staticmethod(bind2(AgentRunActivities.retrieve)) + create = staticmethod(bind2(AgentRunActivities.create)) + + +class LoadedAgentRun(Loaded, AgentRun): + """An agent run row that is also the place its activities live.""" + + model_config = {**AgentRun.model_config, "arbitrary_types_allowed": True} + + if TYPE_CHECKING: + # Type-checker-only declaration; `AgentRuns._load` sets it with + # `object.__setattr__`. A runtime annotation would become a pydantic + # private attribute. + _resources: AgentRuns + + @property + def activities(self) -> _OwnedAgentRunActivities: + return cast( + "_OwnedAgentRunActivities", + Owned(self._resources.activities, self._ids, self._id_names), + ) diff --git a/plane/api/v2/_loaded/workspace.py b/plane/api/v2/_loaded/workspace.py index 430fff8..e089ee4 100644 --- a/plane/api/v2/_loaded/workspace.py +++ b/plane/api/v2/_loaded/workspace.py @@ -14,6 +14,7 @@ from .._kernel.loaded import Loaded, Owned, bind1 if TYPE_CHECKING: + from ..agent_runs import AgentRuns, WorkspaceAgentRunActivities from ..artifacts import Artifacts from ..assets import WorkspaceAssets from ..audit_logs import AuditLogs @@ -67,6 +68,18 @@ class _OwnedProjects(Owned["Projects"]): summary = staticmethod(bind1(Projects.summary)) role_distribution = staticmethod(bind1(Projects.role_distribution)) + class _OwnedAgentRuns(Owned["AgentRuns"]): + list = staticmethod(bind1(AgentRuns.list)) + iterate = staticmethod(bind1(AgentRuns.iterate)) + retrieve = staticmethod(bind1(AgentRuns.retrieve)) + create = staticmethod(bind1(AgentRuns.create)) + update = staticmethod(bind1(AgentRuns.update)) + stop = staticmethod(bind1(AgentRuns.stop)) + + class _OwnedWorkspaceAgentRunActivities(Owned["WorkspaceAgentRunActivities"]): + list = staticmethod(bind1(WorkspaceAgentRunActivities.list)) + iterate = staticmethod(bind1(WorkspaceAgentRunActivities.iterate)) + class _OwnedWorkspaceFeatures(Owned["WorkspaceFeatures"]): retrieve = staticmethod(bind1(WorkspaceFeatures.retrieve)) update = staticmethod(bind1(WorkspaceFeatures.update)) @@ -256,7 +269,7 @@ class _OwnedWorkspaceWorkItems(Owned["WorkspaceWorkItems"]): class LoadedWorkspace(Loaded, Workspace): """A workspace row that is also the place its children live. - Every one of the twenty-five resources `Workspaces.__init__` attaches is + Every one of the twenty-seven resources `Workspaces.__init__` attaches is reachable here, so `workspace.projects.list()` works exactly the way `workspace.roles.list()` does, with the slug supplied once at fetch time. @@ -303,6 +316,20 @@ def release_tags(self) -> _OwnedReleaseTags: Owned(self._resources.release_tags, self._ids, self._id_names), ) + @property + def agent_runs(self) -> _OwnedAgentRuns: + return cast( + "_OwnedAgentRuns", + Owned(self._resources.agent_runs, self._ids, self._id_names), + ) + + @property + def agent_run_activities(self) -> _OwnedWorkspaceAgentRunActivities: + return cast( + "_OwnedWorkspaceAgentRunActivities", + Owned(self._resources.agent_run_activities, self._ids, self._id_names), + ) + @property def artifacts(self) -> _OwnedArtifacts: return cast( diff --git a/plane/api/v2/agent_runs/__init__.py b/plane/api/v2/agent_runs/__init__.py new file mode 100644 index 0000000..7960b84 --- /dev/null +++ b/plane/api/v2/agent_runs/__init__.py @@ -0,0 +1,192 @@ +"""Agent runs (api_v2) -- `/workspaces/{slug}/runs/`: the work an agent (an installed +app's bot) does in a workspace, usually in answer to a work item comment. A run's +append-only log is its `.activities`; the activities on every run at once are +`client.v2.workspaces.agent_run_activities`. + +Who sees what: +- a bot token sees only its own runs: another agent's run is absent from a list and + answers 404 by id; +- a person sees a run only if they can see its work item; a run with no work item (a + scheduled native-agent run, say) is visible to every workspace admin and member; +- guests can't use agent runs at all. + +Who may write: a person starts a run on a comment (`create`), replies with a `prompt` +activity and asks a run to `stop`; only the run's agent may `update` it (settling it as +`completed`) or post its output activities. + +Polling: `list(..., paginate="cursor")` walks oldest change first (`updated_at`), and a +sync read -- a cursor walk or an `updated_at__gte=` poll -- holds back rows changed in the +last 3 seconds, so a run updated mid-walk is read again rather than skipped. Dedupe by +`id`. Webhooks for agent runs carry the same payload as v1, whichever API version made +the change. + +A fetched row comes back as a `LoadedAgentRun`, which reaches `run.activities.list()` +without repeating `slug`/`run`.""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence + +from typing_extensions import Unpack + +from ....models.v2.agent_runs import AgentRun, CreateAgentRun, UpdateAgentRun +from .._generated.constants import ( + RunsCreateField, + RunsListField, + RunsListFilters, + RunsListOrderBy, + RunsPartialUpdateField, + RunsRetrieveField, + RunsStopField, +) +from .._kernel.loaded import LoadsNavigableRows +from .._kernel.pagination import Page, PaginateStyle +from .._kernel.resource import V2Resource +from .._kernel.transport import V2Transport +from .._loaded.agent_run import LoadedAgentRun +from .activities import AgentRunActivities, WorkspaceAgentRunActivities + +__all__ = ["AgentRunActivities", "AgentRuns", "WorkspaceAgentRunActivities"] + + +class AgentRuns( + V2Resource[AgentRun, CreateAgentRun, UpdateAgentRun], LoadsNavigableRows[LoadedAgentRun] +): + """There is no `delete`: a run is settled, not removed.""" + + path = "/workspaces/{slug}/runs/" + model = AgentRun + loaded_model = LoadedAgentRun + loaded_names = ("slug", "run") + operations = { + "list": "runs_list", + "retrieve": "runs_retrieve", + "create": "runs_create", + "update": "runs_partial_update", + "stop": "runs_stop", + } + + def __init__(self, transport: V2Transport) -> None: + super().__init__(transport) + self.activities = AgentRunActivities(transport) + + def list( + self, + slug: str, + *, + fields: Sequence[RunsListField] | None = None, + expand: Sequence[str] | None = None, + order_by: RunsListOrderBy | None = None, + per_page: int | None = None, + offset: int | None = None, + paginate: PaginateStyle | None = None, + cursor: str | None = None, + count: bool | None = None, + **filters: Unpack[RunsListFilters], + ) -> Page[LoadedAgentRun]: + """One page of the runs the caller can see, oldest change first (`updated_at`). + + Filter with `agent_user_id`, `status` / `status__in`, `type`, `trigger_type`, + `work_item_id`, `project_id` or `updated_at__gte`. `expand` takes `agent_user`, + `creator`, `work_item`, `comment` and `source_comment`. With `paginate="cursor"` + or `updated_at__gte`, rows changed in the last 3 seconds are held back until a + later read -- dedupe by `id`.""" + page = self._list( + params={ + "fields": fields, + "expand": expand, + "order_by": order_by, + "per_page": per_page, + "offset": offset, + "paginate": paginate, + "cursor": cursor, + "count": count, + **filters, + }, + slug=slug, + ) + return self._load_page(page, slug, fields=fields) + + def iterate( + self, + slug: str, + *, + fields: Sequence[RunsListField] | None = None, + expand: Sequence[str] | None = None, + order_by: RunsListOrderBy | None = None, + per_page: int | None = None, + paginate: PaginateStyle | None = None, + cursor: str | None = None, + **filters: Unpack[RunsListFilters], + ) -> Iterator[LoadedAgentRun]: + """Every run the caller can see, following pages automatically.""" + rows = self._iter( + params={ + "fields": fields, + "expand": expand, + "order_by": order_by, + "per_page": per_page, + "paginate": paginate, + "cursor": cursor, + **filters, + }, + slug=slug, + ) + return (self._load(row, slug, fields=fields) for row in rows) + + def retrieve( + self, + slug: str, + run: str, + *, + fields: Sequence[RunsRetrieveField] | None = None, + expand: Sequence[str] | None = None, + ) -> LoadedAgentRun: + """A run by id. One the caller can't see answers 404, never 403.""" + row = self._retrieve(pk=run, params={"fields": fields, "expand": expand}, slug=slug) + return self._load(row, slug, fields=fields) + + def create( + self, + slug: str, + data: CreateAgentRun, + *, + fields: Sequence[RunsCreateField] | None = None, + expand: Sequence[str] | None = None, + ) -> LoadedAgentRun: + """Start a run of the agent `data.agent_slug` on the top-level comment + `data.comment_id`. A person must be able to comment on that comment's work item; + a bot token may start only its own runs.""" + row = self._create(data, params={"fields": fields, "expand": expand}, slug=slug) + return self._load(row, slug, fields=fields) + + def update( + self, + slug: str, + run: str, + data: UpdateAgentRun, + *, + fields: Sequence[RunsPartialUpdateField] | None = None, + expand: Sequence[str] | None = None, + ) -> LoadedAgentRun: + """The run's agent reports its title, summary and usage, and settles the run with + `status="completed"`. Bot tokens only: a person gets 403 and ends a run with + `stop` instead.""" + row = self._update(data, pk=run, params={"fields": fields, "expand": expand}, slug=slug) + return self._load(row, slug, fields=fields) + + def stop( + self, + slug: str, + run: str, + *, + fields: Sequence[RunsStopField] | None = None, + expand: Sequence[str] | None = None, + ) -> LoadedAgentRun: + """Ask the run to stop -- the web app's Stop button. Records a `prompt` activity + with `signal="stop"` and moves the run to `stopping`; the agent's next response + settles it as `stopped`. People only: a bot token gets 403 (an agent ends its own + run with `update(status="completed")` or an `error` activity). 409 if the run + has already ended or is already stopping.""" + row = self._action("stop", pk=run, params={"fields": fields, "expand": expand}, slug=slug) + return self._load(row, slug, fields=fields) diff --git a/plane/api/v2/agent_runs/activities.py b/plane/api/v2/agent_runs/activities.py new file mode 100644 index 0000000..5bccb54 --- /dev/null +++ b/plane/api/v2/agent_runs/activities.py @@ -0,0 +1,216 @@ +"""Agent run activities (api_v2) -- a run's append-only log, and the one feed of the +activities on every run the caller can see. + +Both list oldest first (`created_at`) and page by cursor on `(created_at, id)`. There is +no update or delete: an activity, once written, stays.""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence + +from typing_extensions import Unpack + +from ....models.v2.agent_runs import AgentRunActivity, CreateAgentRunActivity +from .._generated.constants import ( + RunActivitiesCreateField, + RunActivitiesListField, + RunActivitiesListFilters, + RunActivitiesListOrderBy, + RunActivitiesRetrieveField, + WorkspaceRunActivitiesListField, + WorkspaceRunActivitiesListFilters, + WorkspaceRunActivitiesListOrderBy, +) +from .._kernel.pagination import Page, PaginateStyle +from .._kernel.resource import V2Resource + +__all__ = ["AgentRunActivities", "WorkspaceAgentRunActivities"] + + +# No patch DTO exists for activities -- the third type parameter is filled with the +# write model as an unused placeholder: the kernel has no append-only resource shape. +class AgentRunActivities( + V2Resource[AgentRunActivity, CreateAgentRunActivity, CreateAgentRunActivity] +): + """`/workspaces/{slug}/runs/{run_id}/activities/` -- one run's log. + + Reach it off a fetched run (`run.activities.list()`) or on the flat path + (`client.v2.workspaces.agent_runs.activities.list(slug, run)`). A run the caller + can't see answers 404 on every method here.""" + + path = "/workspaces/{slug}/runs/{run_id}/activities/" + model = AgentRunActivity + operations = { + "list": "run_activities_list", + "retrieve": "run_activities_retrieve", + "create": "run_activities_create", + } + + def list( + self, + slug: str, + run: str, + *, + fields: Sequence[RunActivitiesListField] | None = None, + expand: Sequence[str] | None = None, + order_by: RunActivitiesListOrderBy | None = None, + per_page: int | None = None, + offset: int | None = None, + paginate: PaginateStyle | None = None, + cursor: str | None = None, + count: bool | None = None, + **filters: Unpack[RunActivitiesListFilters], + ) -> Page[AgentRunActivity]: + """One page of the run's activities, oldest first. Filter with `type` / + `type__in`, `signal`, `ephemeral`, `actor_id`, `comment_id` or `created_at__gte`; + `expand=["actor"]` inlines who wrote each one.""" + return self._list( + params={ + "fields": fields, + "expand": expand, + "order_by": order_by, + "per_page": per_page, + "offset": offset, + "paginate": paginate, + "cursor": cursor, + "count": count, + **filters, + }, + slug=slug, + run_id=run, + ) + + def iterate( + self, + slug: str, + run: str, + *, + fields: Sequence[RunActivitiesListField] | None = None, + expand: Sequence[str] | None = None, + order_by: RunActivitiesListOrderBy | None = None, + per_page: int | None = None, + paginate: PaginateStyle | None = None, + cursor: str | None = None, + **filters: Unpack[RunActivitiesListFilters], + ) -> Iterator[AgentRunActivity]: + """Every activity on the run, following pages automatically.""" + return self._iter( + params={ + "fields": fields, + "expand": expand, + "order_by": order_by, + "per_page": per_page, + "paginate": paginate, + "cursor": cursor, + **filters, + }, + slug=slug, + run_id=run, + ) + + def retrieve( + self, + slug: str, + run: str, + activity: str, + *, + fields: Sequence[RunActivitiesRetrieveField] | None = None, + expand: Sequence[str] | None = None, + ) -> AgentRunActivity: + return self._retrieve( + pk=activity, params={"fields": fields, "expand": expand}, slug=slug, run_id=run + ) + + def create( + self, + slug: str, + run: str, + data: CreateAgentRunActivity, + *, + fields: Sequence[RunActivitiesCreateField] | None = None, + expand: Sequence[str] | None = None, + ) -> AgentRunActivity: + """Append to the run's log. The run's agent (a bot token) posts its output -- + `thought`, `action`, `response`, `elicitation` or `error`; a person may only post + a `prompt` (`content={"type": "prompt", "body": ...}`), which the server mirrors + into the work item's thread as a reply and answers with that reply's + `comment_id`. A person must be able to comment on the run's work item.""" + return self._create( + data, params={"fields": fields, "expand": expand}, slug=slug, run_id=run + ) + + +# Read-only -- the type parameters are filled with the read model itself as an unused +# placeholder: the kernel has no read-only resource shape. +class WorkspaceAgentRunActivities(V2Resource[AgentRunActivity, AgentRunActivity, AgentRunActivity]): + """`/workspaces/{slug}/runs/activities/` -- one feed of the activities on every run + the caller can see (for a bot token, every activity on its own runs), so an agent + keeps a single cursor instead of polling run by run. + + Workspace-scoped, so it hangs off the workspace + (`client.v2.workspaces.agent_run_activities`), not off a run.""" + + path = "/workspaces/{slug}/runs/activities/" + model = AgentRunActivity + operations = {"list": "workspace_run_activities_list"} + + def list( + self, + slug: str, + *, + fields: Sequence[WorkspaceRunActivitiesListField] | None = None, + expand: Sequence[str] | None = None, + order_by: WorkspaceRunActivitiesListOrderBy | None = None, + per_page: int | None = None, + offset: int | None = None, + paginate: PaginateStyle | None = None, + cursor: str | None = None, + count: bool | None = None, + **filters: Unpack[WorkspaceRunActivitiesListFilters], + ) -> Page[AgentRunActivity]: + """One page of the feed, oldest first. Filter with `agent_run_id`, `type` / + `type__in`, `signal`, `ephemeral`, `actor_id`, `comment_id` or `created_at__gte`. + + A sync read -- a cursor walk or a `created_at__gte=` poll -- holds back + activities written in the last 3 seconds until a later read, so dedupe by + `id`.""" + return self._list( + params={ + "fields": fields, + "expand": expand, + "order_by": order_by, + "per_page": per_page, + "offset": offset, + "paginate": paginate, + "cursor": cursor, + "count": count, + **filters, + }, + slug=slug, + ) + + def iterate( + self, + slug: str, + *, + fields: Sequence[WorkspaceRunActivitiesListField] | None = None, + expand: Sequence[str] | None = None, + order_by: WorkspaceRunActivitiesListOrderBy | None = None, + per_page: int | None = None, + paginate: PaginateStyle | None = None, + cursor: str | None = None, + **filters: Unpack[WorkspaceRunActivitiesListFilters], + ) -> Iterator[AgentRunActivity]: + """The whole feed, following pages automatically.""" + return self._iter( + params={ + "fields": fields, + "expand": expand, + "order_by": order_by, + "per_page": per_page, + "paginate": paginate, + "cursor": cursor, + **filters, + }, + slug=slug, + ) diff --git a/plane/api/v2/workspaces.py b/plane/api/v2/workspaces.py index 5ed0c7b..43454a2 100644 --- a/plane/api/v2/workspaces.py +++ b/plane/api/v2/workspaces.py @@ -24,6 +24,7 @@ from ._kernel.resource import V2Resource from ._kernel.transport import V2Transport from ._loaded.workspace import LoadedWorkspace +from .agent_runs import AgentRuns, WorkspaceAgentRunActivities from .artifacts import Artifacts from .assets import WorkspaceAssets from .audit_logs import AuditLogs @@ -67,6 +68,8 @@ def __init__(self, transport: V2Transport) -> None: self.features = WorkspaceFeatures(transport) self.releases = Releases(transport) self.release_tags = ReleaseTags(transport) + self.agent_runs = AgentRuns(transport) + self.agent_run_activities = WorkspaceAgentRunActivities(transport) self.artifacts = Artifacts(transport) self.assets = WorkspaceAssets(transport) self.audit_logs = AuditLogs(transport) diff --git a/plane/models/v2/__init__.py b/plane/models/v2/__init__.py index 0eca328..427033b 100644 --- a/plane/models/v2/__init__.py +++ b/plane/models/v2/__init__.py @@ -1,3 +1,15 @@ +from .agent_runs import ( + AgentRun, + AgentRunActivity, + AgentRunActivitySignal, + AgentRunActivityType, + AgentRunLite, + AgentRunStatus, + AgentRunType, + CreateAgentRun, + CreateAgentRunActivity, + UpdateAgentRun, +) from .artifacts import ( Artifact, ArtifactDetail, @@ -314,6 +326,16 @@ from .workspaces import Workspace __all__ = [ + "AgentRun", + "AgentRunActivity", + "AgentRunActivitySignal", + "AgentRunActivityType", + "AgentRunLite", + "AgentRunStatus", + "AgentRunType", + "CreateAgentRun", + "CreateAgentRunActivity", + "UpdateAgentRun", "Access", "ActorType", "Artifact", diff --git a/plane/models/v2/agent_runs.py b/plane/models/v2/agent_runs.py new file mode 100644 index 0000000..46238d6 --- /dev/null +++ b/plane/models/v2/agent_runs.py @@ -0,0 +1,159 @@ +"""Agent run models for api_v2. + +A run is one piece of work an agent (an installed app's bot) does in a workspace, +usually in answer to a work item comment. Its activities are the run's append-only +log: the agent's `thought`/`action`/`response`/`elicitation`/`error` output and the +`prompt` replies people send it.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + +AgentRunStatus = Literal[ + "created", + "in_progress", + "awaiting", + "completed", + "stopping", + "stopped", + "failed", + "stale", +] +AgentRunType = Literal[ + "comment_thread", + "scheduled", + "assignment", + "event_triggered", + "integration_triggered", +] +AgentRunActivityType = Literal["action", "elicitation", "error", "prompt", "response", "thought"] +AgentRunActivitySignal = Literal["auth_request", "continue", "select", "stop", "upgrade"] + + +class AgentRun(BaseModel): + """One agent run. Every field is read-only on the wire; `status` moves as the agent + reports progress, a person stops the run, or the server marks it `stale`.""" + + model_config = ConfigDict(extra="allow") + + id: str + agent_user_id: str | None = None + comment_id: str | None = None + """The top-level comment that is the run's thread root.""" + created_at: datetime | None = None + created_by_id: str | None = None + creator_id: str | None = None + credit_usage: dict[str, Any] | None = None + ended_at: datetime | None = None + error_metadata: dict[str, Any] | None = None + external_link: str | None = None + external_trigger_id: str | None = None + external_trigger_source: str | None = None + native_agent_id: str | None = None + project_id: str | None = None + source_comment_id: str | None = None + started_at: datetime | None = None + status: AgentRunStatus | None = None + stopped_at: datetime | None = None + stopped_by_id: str | None = None + summary: str | None = None + title: str | None = None + token_usage: dict[str, Any] | None = None + trigger_type: str | None = None + type: AgentRunType | None = None + updated_at: datetime | None = None + updated_by_id: str | None = None + work_item_id: str | None = None + + +class CreateAgentRun(BaseModel): + """POST body: start a run of the agent `agent_slug` (its app's slug) on a top-level + comment. The run takes its work item and project from the comment; the server sets + the creator and resolves the agent's bot user.""" + + model_config = ConfigDict(extra="ignore") + + agent_slug: str = Field(min_length=1) + comment_id: str + title: str | None = None + summary: str | None = None + external_link: str | None = None + + +class UpdateAgentRun(BaseModel): + """PATCH body. Only the run's own agent (a bot token) may send it: the agent settles + its run with `status="completed"` and reports its title, summary and usage. A person + ends a run with `stop` instead.""" + + model_config = ConfigDict(extra="ignore") + + title: str | None = None + summary: str | None = None + token_usage: dict[str, Any] | None = None + credit_usage: dict[str, Any] | None = None + status: Literal["completed"] | None = None + + +class AgentRunActivity(BaseModel): + """One entry in a run's activity log. + + `content` is the activity's payload: `{"type": , "body": }` for every + type but `action`, which is `{"type": "action", "action": , "parameters": + {...}}`. `content_metadata` is stored and echoed verbatim.""" + + model_config = ConfigDict(extra="allow") + + id: str + actor_id: str | None = None + agent_run_id: str | None = None + comment_id: str | None = None + """The work item comment this activity was mirrored to, if any.""" + content: dict[str, Any] | None = None + content_metadata: dict[str, Any] | None = None + created_at: datetime | None = None + created_by_id: str | None = None + ephemeral: bool | None = None + project_id: str | None = None + signal: AgentRunActivitySignal | None = None + signal_metadata: dict[str, Any] | None = None + type: AgentRunActivityType | None = None + updated_at: datetime | None = None + updated_by_id: str | None = None + + +class CreateAgentRunActivity(BaseModel): + """POST body. Who posts decides what may be posted: + + - the run's agent (a bot token) posts its output -- `thought`, `action`, `response`, + `elicitation` or `error` -- and may set `signal`, `ephemeral` and `comment_id`; + - a person may only reply with a `prompt` (`content={"type": "prompt", "body": ...}`), + with no signal, not ephemeral and no `comment_id`: the server mirrors the prompt + into the work item's thread itself. To end a run, a person calls `stop`. + + `content_metadata` is stored and echoed verbatim, so a client can tag an activity + (an idempotency key, say) and match on it later.""" + + model_config = ConfigDict(extra="ignore") + + type: AgentRunActivityType + content: dict[str, Any] | None = None + content_metadata: dict[str, Any] | None = None + ephemeral: bool | None = None + signal: AgentRunActivitySignal | None = None + signal_metadata: dict[str, Any] | None = None + comment_id: str | None = None + + +class AgentRunLite(BaseModel): + """The run a comment is the thread root of, as `expand=["agent_run"]` returns it on a + work item comment.""" + + model_config = ConfigDict(extra="allow") + + id: str + status: AgentRunStatus | None = None + agent_user_id: str | None = None + native_agent_id: str | None = None diff --git a/plane/models/v2/work_items.py b/plane/models/v2/work_items.py index 668dc37..9f7a0fe 100644 --- a/plane/models/v2/work_items.py +++ b/plane/models/v2/work_items.py @@ -8,6 +8,8 @@ from pydantic import BaseModel, ConfigDict +from .agent_runs import AgentRunLite + Priority = Literal["none", "low", "medium", "high", "urgent"] CommentAccess = Literal["INTERNAL", "EXTERNAL"] DependencyRelationType = Literal[ @@ -119,6 +121,11 @@ class WorkItemComment(BaseModel): id: str access: CommentAccess | None = None actor_id: str | None = None + agent_run_id: str | None = None + """The agent run this comment is the thread root of, or null: set on the comment that + started a run, null on the replies inside its thread. With several runs, the oldest.""" + agent_run: AgentRunLite | None = None + """Populated only when the request set `expand=["agent_run"]`.""" comment_html: str | None = None comment_stripped: str | None = None created_at: datetime | None = None diff --git a/pyproject.toml b/pyproject.toml index 8aba112..6ba90d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plane-sdk" -version = "0.3.3" +version = "0.3.4" description = "Python SDK for Plane API" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/v2/integration/conftest.py b/tests/v2/integration/conftest.py index 7533238..bcacd28 100644 --- a/tests/v2/integration/conftest.py +++ b/tests/v2/integration/conftest.py @@ -27,7 +27,7 @@ from plane.models.v2.projects import CreateProject from plane.models.v2.work_items import CreateWorkItem -from ._guard import register +from ._guard import register, skip_absent_capability from .helpers import SPECS, ResourceSpec, unique_name @@ -87,9 +87,39 @@ def client(base_url: str, api_key: str) -> PlaneClient: return plane_client +@pytest.fixture(scope="session") +def agent_slug() -> str: + """The slug of an agent app installed in the test workspace -- agent runs need an + agent to run. Optional: without it the agent-run tests skip as a declared capability + gap (the workspace has no agent for them), and every other test still runs.""" + slug = os.getenv("AGENT_SLUG") + if not slug: + skip_absent_capability( + "no agent app to start runs with -- set AGENT_SLUG to an app installed in " + "the workspace" + ) + return slug + + +@pytest.fixture(scope="session") +def agent_client(base_url: str) -> PlaneClient: + """A client authenticated as that agent's bot (`AGENT_BOT_TOKEN`, an access token + the app was issued in the test workspace) -- the only principal the API lets update + a run or post the agent's output activities. Optional, like `agent_slug`.""" + token = os.getenv("AGENT_BOT_TOKEN") + if not token: + skip_absent_capability( + "no agent bot token to act as the agent with -- set AGENT_BOT_TOKEN to an " + "access token of the AGENT_SLUG app's bot" + ) + plane_client = PlaneClient(base_url=base_url, access_token=token) + _install_rate_limit_retry(plane_client.v2.transport) + return plane_client + + @pytest.fixture(scope="session") def workspace(client: PlaneClient, workspace_slug: str) -> LoadedWorkspace: - """The test workspace as a loaded row, reaching all 25 workspace-scoped families + """The test workspace as a loaded row, reaching all 27 workspace-scoped families with the slug bound once (`workspace.roles.list()`). The suite deliberately uses **both** ways into a resource, file by file, and says diff --git a/tests/v2/integration/test_agent_runs.py b/tests/v2/integration/test_agent_runs.py new file mode 100644 index 0000000..226436f --- /dev/null +++ b/tests/v2/integration/test_agent_runs.py @@ -0,0 +1,239 @@ +"""Live coverage for agent runs: `client.v2.workspaces.agent_runs`, a run's `activities`, +the workspace-wide `agent_run_activities` feed, and the run fields on work item comments. + +A person (the suite's api-key `client`) starts a run on a comment, prompts it and stops +it; the agent itself (`agent_client`, the app bot's access token) reports on its own run. +Both principals matter: the API splits the writes between them and refuses the wrong one. +Needs `AGENT_SLUG` (and `AGENT_BOT_TOKEN` for the agent's half); without them those tests +skip as a declared capability gap. + +Runs are reached off the loaded `workspace` row and a fetched run's own `activities`, so +the slug and run id are written once; the comment checks use the loaded work item.""" + +from __future__ import annotations + +import time +from collections.abc import Iterator +from typing import Any + +import pytest + +from plane.api.v2 import LoadedAgentRun, LoadedProject, LoadedWorkspace, PlaneAPIError +from plane.client import PlaneClient +from plane.models.v2.agent_runs import CreateAgentRun, CreateAgentRunActivity, UpdateAgentRun +from plane.models.v2.work_items import CreateWorkItem, CreateWorkItemComment + +from .helpers import unique_name + +SYNC_HOLD_BACK_SECONDS = 3.5 +"""A sync read (a cursor walk or a `*__gte` poll) holds back rows stamped in the last 3 +seconds; waiting a little longer makes a just-written row visible to one.""" + + +@pytest.fixture +def work_item(project: LoadedProject) -> Iterator[Any]: + created = project.work_items.create(CreateWorkItem(name=unique_name("wi-agent-run"))) + yield created + try: + project.work_items.delete(created.id) + except Exception: + pass + + +@pytest.fixture +def thread(work_item: Any) -> Any: + """The top-level comment a run is started on -- it becomes the run's thread root.""" + return work_item.comments.create( + CreateWorkItemComment(comment_html="

Write a poem about this work item

") + ) + + +@pytest.fixture +def run(workspace: LoadedWorkspace, agent_slug: str, thread: Any) -> LoadedAgentRun: + """A run the person started. Runs can't be deleted -- the work item's cleanup is the + only teardown there is.""" + return workspace.agent_runs.create( + CreateAgentRun(agent_slug=agent_slug, comment_id=thread.id, title=unique_name("run")) + ) + + +class TestRunsAsAPerson: + def test_create_takes_the_comments_work_item( + self, run: LoadedAgentRun, thread: Any, work_item: Any + ) -> None: + assert run.comment_id == thread.id + assert run.source_comment_id == thread.id + assert run.work_item_id == work_item.id + assert run.status in {"created", "in_progress"} + assert run.type == "comment_thread" + + def test_retrieve_expands_the_thread_comment_and_work_item( + self, workspace: LoadedWorkspace, run: LoadedAgentRun, thread: Any, work_item: Any + ) -> None: + fetched = workspace.agent_runs.retrieve(run.id, expand=["comment", "work_item"]) + + body = fetched.model_dump() + assert body["comment"]["id"] == thread.id + assert body["work_item"]["id"] == work_item.id + + def test_list_filters_by_work_item_and_status( + self, workspace: LoadedWorkspace, run: LoadedAgentRun, work_item: Any + ) -> None: + page = workspace.agent_runs.list( + work_item_id=work_item.id, status__in=["created", "in_progress", "awaiting"] + ) + assert [row.id for row in page.data] == [run.id] + + none = workspace.agent_runs.list(work_item_id=work_item.id, status="completed") + assert none.data == [] + + def test_a_cursor_walk_reaches_the_run_once_the_hold_back_passes( + self, workspace: LoadedWorkspace, run: LoadedAgentRun, work_item: Any + ) -> None: + time.sleep(SYNC_HOLD_BACK_SECONDS) + + rows = list( + workspace.agent_runs.iterate(work_item_id=work_item.id, paginate="cursor", per_page=1) + ) + + assert [row.id for row in rows] == [run.id] + assert rows[0].activities._ids[-1] == run.id + + def test_a_person_cannot_update_a_run( + self, workspace: LoadedWorkspace, run: LoadedAgentRun + ) -> None: + with pytest.raises(PlaneAPIError) as caught: + workspace.agent_runs.update(run.id, UpdateAgentRun(summary="forged")) + assert caught.value.status == 403 + + def test_stop_moves_the_run_to_stopping_and_only_once( + self, workspace: LoadedWorkspace, run: LoadedAgentRun + ) -> None: + stopped = workspace.agent_runs.stop(run.id) + assert stopped.status == "stopping" + + with pytest.raises(PlaneAPIError) as caught: + workspace.agent_runs.stop(run.id) + assert caught.value.status == 409 + + signals = [row.signal for row in stopped.activities.iterate(type="prompt")] + assert "stop" in signals + + def test_an_unknown_run_is_a_404(self, workspace: LoadedWorkspace) -> None: + with pytest.raises(PlaneAPIError) as caught: + workspace.agent_runs.retrieve("00000000-0000-4000-8000-000000000000") + assert caught.value.status == 404 + + +class TestActivitiesAsAPerson: + def test_a_prompt_is_logged_and_mirrored_into_the_thread( + self, workspace: LoadedWorkspace, run: LoadedAgentRun, work_item: Any + ) -> None: + prompt = run.activities.create( + CreateAgentRunActivity( + type="prompt", + content={"type": "prompt", "body": "

Make it rhyme

"}, + content_metadata={"client_ref": "sdk-it"}, + ) + ) + + assert prompt.type == "prompt" + assert prompt.agent_run_id == run.id + assert prompt.content_metadata == {"client_ref": "sdk-it"} + assert prompt.comment_id is not None, "a person's prompt is mirrored as a thread reply" + + fetched = run.activities.retrieve(prompt.id, expand=["actor"]) + assert fetched.content == {"type": "prompt", "body": "

Make it rhyme

"} + + logged = {row.id for row in run.activities.iterate(type="prompt")} + assert prompt.id in logged + + reply = work_item.comments.retrieve(prompt.comment_id) + assert "Make it rhyme" in (reply.comment_html or "") + + def test_the_workspace_feed_carries_the_runs_activity( + self, workspace: LoadedWorkspace, run: LoadedAgentRun + ) -> None: + prompt = run.activities.create( + CreateAgentRunActivity(type="prompt", content={"type": "prompt", "body": "

Go

"}) + ) + + page = workspace.agent_run_activities.list(agent_run_id=run.id, type="prompt") + assert prompt.id in {row.id for row in page.data} + + assert prompt.created_at is not None + time.sleep(SYNC_HOLD_BACK_SECONDS) + polled = workspace.agent_run_activities.iterate( + agent_run_id=run.id, created_at__gte=prompt.created_at.isoformat(), paginate="cursor" + ) + assert prompt.id in {row.id for row in polled} + + def test_a_person_cannot_post_the_agents_output(self, run: LoadedAgentRun) -> None: + with pytest.raises(PlaneAPIError) as caught: + run.activities.create( + CreateAgentRunActivity( + type="response", content={"type": "response", "body": "forged"} + ) + ) + assert caught.value.status == 400 + + +class TestCommentsCarryTheRun: + def test_the_thread_comment_names_its_run( + self, run: LoadedAgentRun, thread: Any, work_item: Any + ) -> None: + fetched = work_item.comments.retrieve(thread.id, expand=["agent_run"]) + + assert fetched.agent_run_id == run.id + assert fetched.agent_run is not None + assert fetched.agent_run.id == run.id + assert fetched.agent_run.agent_user_id == run.agent_user_id + + def test_comments_filter_by_agent_run_id( + self, run: LoadedAgentRun, thread: Any, work_item: Any + ) -> None: + work_item.comments.create(CreateWorkItemComment(comment_html="

unrelated

")) + + page = work_item.comments.list(agent_run_id=run.id, fields=["id", "agent_run_id"]) + + assert [(row.id, row.agent_run_id) for row in page.data] == [(thread.id, run.id)] + + +class TestRunsAsTheAgent: + def test_the_agent_reports_and_completes_its_run( + self, agent_client: PlaneClient, workspace_slug: str, run: LoadedAgentRun + ) -> None: + agent_runs = agent_client.v2.workspaces.agent_runs + mine = agent_runs.retrieve(workspace_slug, run.id) + + thought = mine.activities.create( + CreateAgentRunActivity( + type="thought", content={"type": "thought", "body": "Reading the work item"} + ) + ) + assert thought.type == "thought" + + done = agent_runs.update( + workspace_slug, + run.id, + UpdateAgentRun(status="completed", summary="Wrote the poem.", token_usage={"in": 7}), + ) + assert done.status == "completed" + assert done.summary == "Wrote the poem." + assert done.ended_at is not None + + def test_the_agent_lists_its_own_runs( + self, agent_client: PlaneClient, workspace_slug: str, run: LoadedAgentRun + ) -> None: + assert run.agent_user_id is not None and run.work_item_id is not None + page = agent_client.v2.workspaces.agent_runs.list( + workspace_slug, agent_user_id=run.agent_user_id, work_item_id=run.work_item_id + ) + assert [row.id for row in page.data] == [run.id] + + def test_the_agent_cannot_stop_its_run( + self, agent_client: PlaneClient, workspace_slug: str, run: LoadedAgentRun + ) -> None: + with pytest.raises(PlaneAPIError) as caught: + agent_client.v2.workspaces.agent_runs.stop(workspace_slug, run.id) + assert caught.value.status == 403 diff --git a/tests/v2/test_agent_runs_resource.py b/tests/v2/test_agent_runs_resource.py new file mode 100644 index 0000000..f28cf9f --- /dev/null +++ b/tests/v2/test_agent_runs_resource.py @@ -0,0 +1,374 @@ +"""Offline coverage for `AgentRuns`, its `activities` child and the workspace-wide +`agent_run_activities` feed: every operation's URL, method, query and body, the DTO +rules the API enforces, and navigation from a fetched run and a fetched workspace.""" + +import json +from urllib.parse import parse_qs, urlparse + +import pytest +import responses +from pydantic import ValidationError + +from plane.api.v2 import LoadedAgentRun, PlaneAPIError, V2Namespace +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.agent_runs import AgentRuns, WorkspaceAgentRunActivities +from plane.config import Configuration +from plane.models.v2.agent_runs import ( + AgentRunActivity, + CreateAgentRun, + CreateAgentRunActivity, + UpdateAgentRun, +) + +ROOT = "https://api.example.com/api/v2/workspaces/acme" +RUNS = f"{ROOT}/runs" +RUN = { + "id": "r1", + "agent_user_id": "bot-1", + "comment_id": "c1", + "source_comment_id": "c1", + "work_item_id": "w1", + "project_id": "p1", + "status": "in_progress", + "type": "comment_thread", + "title": "Write a poem", + "token_usage": {}, + "started_at": "2026-11-10T09:00:00Z", + "updated_at": "2026-11-10T09:00:05Z", +} +PROMPT = { + "id": "a1", + "agent_run_id": "r1", + "type": "prompt", + "content": {"type": "prompt", "body": "

Make it rhyme

"}, + "signal": "continue", + "ephemeral": False, + "comment_id": "c2", +} + + +@pytest.fixture +def runs(config: Configuration) -> AgentRuns: + return AgentRuns(V2Transport(config)) + + +@pytest.fixture +def feed(config: Configuration) -> WorkspaceAgentRunActivities: + return WorkspaceAgentRunActivities(V2Transport(config)) + + +def _query(call_index: int = 0) -> dict[str, list[str]]: + return parse_qs(urlparse(responses.calls[call_index].request.url).query) + + +def _body(call_index: int = 0) -> object: + body = responses.calls[call_index].request.body + return json.loads(body) if body else None + + +# -- Runs --------------------------------------------------------------------------- + + +@responses.activate +def test_list_sends_filters_expand_and_the_cursor_envelope(runs: AgentRuns) -> None: + responses.get( + f"{RUNS}/", + json={ + "data": [RUN], + "pagination": {"style": "cursor"}, + "has_more": False, + "next_cursor": None, + }, + ) + + page = runs.list( + "acme", + status__in=["in_progress", "awaiting"], + work_item_id="w1", + updated_at__gte="2026-11-10T09:00:00Z", + expand=["agent_user", "work_item"], + order_by="-updated_at", + paginate="cursor", + per_page=50, + ) + + assert _query() == { + "status__in": ["in_progress,awaiting"], + "work_item_id": ["w1"], + "updated_at__gte": ["2026-11-10T09:00:00Z"], + "expand": ["agent_user,work_item"], + "order_by": ["-updated_at"], + "paginate": ["cursor"], + "per_page": ["50"], + } + row = page.data[0] + assert isinstance(row, LoadedAgentRun) + assert row.status == "in_progress" + assert row.activities._ids == ("acme", "r1") + + +def test_list_rejects_options_the_golden_does_not_offer(runs: AgentRuns) -> None: + with pytest.raises(ValueError, match="Unknown order_by"): + runs.list("acme", order_by="title") # type: ignore[arg-type] + with pytest.raises(ValueError, match="Unknown expand"): + runs.list("acme", expand=["actor"]) + with pytest.raises(ValueError, match="Unknown field"): + runs.list("acme", fields=["name"]) # type: ignore[list-item] + + +@responses.activate +def test_iterate_follows_cursor_pages_and_threads_the_ids(runs: AgentRuns) -> None: + responses.get( + f"{RUNS}/", + json={ + "data": [{**RUN, "id": "r1"}], + "pagination": {"style": "cursor"}, + "has_more": True, + "next_cursor": "cur-2", + }, + ) + responses.get( + f"{RUNS}/", + json={ + "data": [{**RUN, "id": "r2"}], + "pagination": {"style": "cursor"}, + "has_more": False, + "next_cursor": None, + }, + ) + + rows = list(runs.iterate("acme", paginate="cursor", status="awaiting")) + + assert [row.id for row in rows] == ["r1", "r2"] + assert rows[1].activities._ids == ("acme", "r2") + assert _query(1) == {"paginate": ["cursor"], "status": ["awaiting"], "cursor": ["cur-2"]} + + +@responses.activate +def test_retrieve_passes_fields_and_expand(runs: AgentRuns) -> None: + responses.get( + f"{RUNS}/r1/", + json={"id": "r1", "status": "completed", "comment": {"id": "c1", "comment_html": "

"}}, + ) + + row = runs.retrieve("acme", "r1", fields=["id", "status"], expand=["comment"]) + + assert row.status == "completed" + assert _query() == {"fields": ["id,status"], "expand": ["comment"]} + + +@responses.activate +def test_create_sends_only_the_fields_set(runs: AgentRuns) -> None: + responses.post(f"{RUNS}/", json={**RUN, "status": "created"}, status=201) + + created = runs.create( + "acme", CreateAgentRun(agent_slug="poem-agent", comment_id="c1", title="Write a poem") + ) + + assert created.status == "created" + assert created.activities._ids == ("acme", "r1") + assert _body() == {"agent_slug": "poem-agent", "comment_id": "c1", "title": "Write a poem"} + + +def test_create_needs_an_agent_slug_and_a_comment() -> None: + with pytest.raises(ValidationError): + CreateAgentRun(agent_slug="", comment_id="c1") + with pytest.raises(ValidationError): + CreateAgentRun(agent_slug="poem-agent") # type: ignore[call-arg] + + +@responses.activate +def test_update_patches_the_agents_report(runs: AgentRuns) -> None: + responses.patch(f"{RUNS}/r1/", json={**RUN, "status": "completed", "summary": "Done."}) + + updated = runs.update( + "acme", + "r1", + UpdateAgentRun(status="completed", summary="Done.", token_usage={"input": 12}), + ) + + assert updated.status == "completed" + assert responses.calls[0].request.method == "PATCH" + assert _body() == {"status": "completed", "summary": "Done.", "token_usage": {"input": 12}} + + +def test_update_only_settles_a_run_as_completed() -> None: + with pytest.raises(ValidationError): + UpdateAgentRun(status="stopped") # type: ignore[arg-type] + + +@responses.activate +def test_stop_posts_with_no_body_and_answers_the_run(runs: AgentRuns) -> None: + responses.post(f"{RUNS}/r1/stop/", json={**RUN, "status": "stopping"}) + + stopped = runs.stop("acme", "r1", fields=["id", "status"]) + + assert stopped.status == "stopping" + assert responses.calls[0].request.body is None + assert _query() == {"fields": ["id,status"]} + assert stopped.activities._ids == ("acme", "r1") + + +@responses.activate +def test_stopping_an_ended_run_raises_the_409(runs: AgentRuns) -> None: + responses.post( + f"{RUNS}/r1/stop/", + status=409, + json={ + "type": "https://developers.plane.so/errors/conflict", + "title": "Conflict", + "status": 409, + "code": "conflict", + "detail": "This agent run has already ended.", + }, + content_type="application/problem+json", + ) + + with pytest.raises(PlaneAPIError) as caught: + runs.stop("acme", "r1") + + assert caught.value.status == 409 + assert caught.value.code == "conflict" + + +def test_runs_cannot_be_deleted(runs: AgentRuns) -> None: + """The API has no DELETE for a run -- it is settled, not removed.""" + assert not hasattr(runs, "delete") + + +# -- Activities on one run ---------------------------------------------------------- + + +@responses.activate +def test_a_fetched_run_lists_its_activities_with_no_ids_repeated(runs: AgentRuns) -> None: + responses.get(f"{RUNS}/r1/", json=RUN) + responses.get( + f"{RUNS}/r1/activities/", + json={"data": [PROMPT], "pagination": {"style": "offset"}, "total_count": 1}, + ) + + run = runs.retrieve("acme", "r1") + page = run.activities.list(type__in=["prompt", "response"], expand=["actor"]) + + assert responses.calls[1].request.url.startswith(f"{RUNS}/r1/activities/?") + assert _query(1) == {"type__in": ["prompt,response"], "expand": ["actor"]} + activity = page.data[0] + assert isinstance(activity, AgentRunActivity) + assert activity.content == {"type": "prompt", "body": "

Make it rhyme

"} + assert activity.comment_id == "c2" + + +@responses.activate +def test_activities_create_and_retrieve_on_the_flat_path(config: Configuration) -> None: + activities = V2Namespace(config).workspaces.agent_runs.activities + responses.post(f"{RUNS}/r1/activities/", json=PROMPT, status=201) + responses.get(f"{RUNS}/r1/activities/a1/", json=PROMPT) + + created = activities.create( + "acme", + "r1", + CreateAgentRunActivity( + type="prompt", + content={"type": "prompt", "body": "

Make it rhyme

"}, + content_metadata={"idempotency_key": "k-1"}, + ), + ) + fetched = activities.retrieve("acme", "r1", "a1", fields=["id", "type"]) + + assert created.id == fetched.id == "a1" + assert _body() == { + "type": "prompt", + "content": {"type": "prompt", "body": "

Make it rhyme

"}, + "content_metadata": {"idempotency_key": "k-1"}, + } + assert _query(1) == {"fields": ["id,type"]} + + +@responses.activate +def test_iterate_activities_follows_offset_pages(config: Configuration) -> None: + activities = V2Namespace(config).workspaces.agent_runs.activities + responses.get( + f"{RUNS}/r1/activities/", + json={"data": [PROMPT], "next": 1, "pagination": {"style": "offset"}}, + ) + responses.get( + f"{RUNS}/r1/activities/", + json={"data": [{**PROMPT, "id": "a2"}], "next": None, "pagination": {"style": "offset"}}, + ) + + assert [row.id for row in activities.iterate("acme", "r1", per_page=1)] == ["a1", "a2"] + + +def test_an_activity_needs_a_known_type() -> None: + with pytest.raises(ValidationError): + CreateAgentRunActivity(type="comment", content={"body": "x"}) # type: ignore[arg-type] + + +# -- The workspace-wide activity feed ----------------------------------------------- + + +@responses.activate +def test_feed_polls_with_the_sync_filters(feed: WorkspaceAgentRunActivities) -> None: + responses.get( + f"{RUNS}/activities/", + json={"data": [PROMPT], "pagination": {"style": "cursor"}, "has_more": False}, + ) + + page = feed.list( + "acme", + agent_run_id="r1", + created_at__gte="2026-11-10T09:00:00Z", + signal="stop", + paginate="cursor", + ) + + assert page.data[0].agent_run_id == "r1" + assert _query() == { + "agent_run_id": ["r1"], + "created_at__gte": ["2026-11-10T09:00:00Z"], + "signal": ["stop"], + "paginate": ["cursor"], + } + + +@responses.activate +def test_feed_iterate_walks_the_cursor(feed: WorkspaceAgentRunActivities) -> None: + responses.get( + f"{RUNS}/activities/", + json={ + "data": [PROMPT], + "pagination": {"style": "cursor"}, + "has_more": True, + "next_cursor": "c-2", + }, + ) + responses.get( + f"{RUNS}/activities/", + json={"data": [{**PROMPT, "id": "a2"}], "pagination": {"style": "cursor"}}, + ) + + assert [row.id for row in feed.iterate("acme", paginate="cursor")] == ["a1", "a2"] + assert _query(1)["cursor"] == ["c-2"] + + +# -- Navigation from a fetched workspace -------------------------------------------- + + +@responses.activate +def test_a_fetched_workspace_reaches_runs_and_the_feed(config: Configuration) -> None: + responses.get(f"{ROOT}/", json={"id": "ws-1", "slug": "acme"}) + responses.get(f"{RUNS}/r1/", json=RUN) + responses.get(f"{RUNS}/r1/activities/", json={"data": [], "pagination": {"style": "offset"}}) + responses.get(f"{RUNS}/activities/", json={"data": [], "pagination": {"style": "offset"}}) + + workspace = V2Namespace(config).workspaces.retrieve("acme") + run = workspace.agent_runs.retrieve("r1") + run.activities.list() + workspace.agent_run_activities.list() + + assert [call.request.url.split("?")[0] for call in responses.calls] == [ + f"{ROOT}/", + f"{RUNS}/r1/", + f"{RUNS}/r1/activities/", + f"{RUNS}/activities/", + ] diff --git a/tests/v2/test_tree.py b/tests/v2/test_tree.py index 06d40e1..72196cf 100644 --- a/tests/v2/test_tree.py +++ b/tests/v2/test_tree.py @@ -4,6 +4,7 @@ import responses from plane.api.v2 import V2Namespace +from plane.api.v2.agent_runs import AgentRunActivities, AgentRuns, WorkspaceAgentRunActivities from plane.api.v2.artifacts import Artifacts from plane.api.v2.assets import WorkspaceAssets from plane.api.v2.audit_logs import AuditLogs @@ -267,6 +268,19 @@ def test_every_resource_class_is_reachable_from_the_namespace(config: Configurat # `Wiki` (see `plane/api/v2/wiki_node.py`), with no `path` of its own -- its three # children each get their own row with a real URL instead. WORKSPACE_TREE_ATTACHMENTS = [ + ("agent_runs", lambda ws: ws.agent_runs, AgentRuns, "/workspaces/acme/runs/"), + ( + "agent_runs.activities", + lambda ws: ws.agent_runs.activities, + AgentRunActivities, + None, # its collection URL carries the run id -- see the test below + ), + ( + "agent_run_activities", + lambda ws: ws.agent_run_activities, + WorkspaceAgentRunActivities, + "/workspaces/acme/runs/activities/", + ), ("artifacts", lambda ws: ws.artifacts, Artifacts, "/workspaces/acme/artifacts/"), ("assets", lambda ws: ws.assets, WorkspaceAssets, "/workspaces/acme/assets/"), ("audit_logs", lambda ws: ws.audit_logs, AuditLogs, "/workspaces/acme/audit-logs/"), @@ -675,6 +689,10 @@ def test_the_newly_attached_workspace_children_reach_their_urls(config: Configur a wrongly-templated child would pass on the `isinstance` half of its row alone.""" ws = V2Namespace(config).workspaces + assert ( + ws.agent_runs.activities._collection_url(slug="acme", run_id="r1") + == "/workspaces/acme/runs/r1/activities/" + ) assert ( ws.customers.requests._collection_url(slug="acme", customer_id="c1") == "/workspaces/acme/customers/c1/requests/" diff --git a/tests/v2/test_work_items_resource.py b/tests/v2/test_work_items_resource.py index 8d8bc8e..f9b0e60 100644 --- a/tests/v2/test_work_items_resource.py +++ b/tests/v2/test_work_items_resource.py @@ -254,6 +254,63 @@ def test_comments_upsert(work_items: WorkItems) -> None: assert result.id == "c1" +@responses.activate +def test_comments_filter_and_expand_by_agent_run(work_items: WorkItems) -> None: + """A comment that started an agent run carries `agent_run_id`; `?agent_run_id=` finds + it, and `expand=["agent_run"]` inlines the run's id, status and agent.""" + responses.get( + f"{BASE}/wi-1/comments/", + json={ + "data": [ + { + "id": "c1", + "agent_run_id": "r1", + "agent_run": { + "id": "r1", + "status": "awaiting", + "agent_user_id": "bot-1", + "native_agent_id": None, + }, + } + ], + "pagination": {"style": "offset"}, + }, + ) + + page = work_items.comments.list( + "acme", + "ENG", + "wi-1", + agent_run_id="r1", + expand=["actor", "agent_run"], + fields=["id", "agent_run_id"], + ) + + query = responses.calls[0].request.url.split("?", 1)[1] + assert "agent_run_id=r1" in query + assert "expand=actor%2Cagent_run" in query + assert "fields=id%2Cagent_run_id" in query + comment = page.data[0] + assert comment.agent_run_id == "r1" + assert comment.agent_run is not None + assert (comment.agent_run.status, comment.agent_run.agent_user_id) == ("awaiting", "bot-1") + + +@responses.activate +def test_a_comment_without_a_run_reads_agent_run_id_as_none(work_items: WorkItems) -> None: + responses.get(f"{BASE}/wi-1/comments/c2/", json={"id": "c2", "agent_run_id": None}) + + comment = work_items.comments.retrieve("acme", "ENG", "wi-1", "c2", expand=["agent_run"]) + + assert comment.agent_run_id is None + assert comment.agent_run is None + + +def test_comments_reject_an_expand_the_golden_does_not_offer(work_items: WorkItems) -> None: + with pytest.raises(ValueError, match="Unknown expand"): + work_items.comments.list("acme", "ENG", "wi-1", expand=["agent_runs"]) + + @responses.activate def test_comments_have_their_own_bulk_operations(work_items: WorkItems) -> None: """Comments' bulk ops (`work_item_comments_bulk_*`) are distinct operationIds