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