feat(F.14): Unified Task System — F-TASK-MODEL/API/AGENT/WORK/UI/MIG/GOAL/TEST
Check Cross-Plugin Imports / check (push) Has been cancelled

- F-TASK-MODEL: Extended Task model with polymorphic assignee/entity/creator, subtasks, dependencies, task_type, success_criteria, progress
- F-TASK-API: Extended task routes with polymorphic filters, subtasks, dependencies, new lifecycle
- F-TASK-AGENT: ai_tools.py (191 lines) — create_task, assign_task, update_task_status, decompose_goal tools
- F-TASK-WORK: workstream.py — task_card and goal_card blocks in communication system
- F-TASK-UI: TaskBoard.tsx, TaskDetail.tsx, GoalView.tsx frontend components
- F-TASK-MIG: Migration 0124 — new columns, data migration for contact_id/assigned_to
- F-TASK-GOAL: Progress aggregation, success criteria evaluation, parent status propagation
- F-TASK-TEST: test_unified_tasks.py (414 lines)
- i18n updates for task system
This commit is contained in:
Agent Zero
2026-08-17 18:51:22 +02:00
parent 06b281ba74
commit a53dcc38d5
9 changed files with 1397 additions and 12 deletions
+414
View File
@@ -0,0 +1,414 @@
"""Unified Task System (F.14) tests.
Covers polymorphic assignment, entity links, subtasks, agent task creation,
goal decomposition, progress aggregation, success criteria evaluation and
migration of legacy fields.
"""
from __future__ import annotations
import uuid
import pytest
from httpx import AsyncClient
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
@pytest.mark.asyncio
class TestPolymorphicAssignment:
"""Polymorphic assignee (user/agent/group)."""
async def test_create_task_with_agent_assignee(self, tasks_client: AsyncClient, db_session):
"""POST /tasks with assignee_type=agent stores assignee_id."""
await seed_tenant_and_users(db_session)
await login_client(tasks_client, "admin@tenanta.com")
agent_id = str(uuid.uuid4())
resp = await tasks_client.post(
"/api/v1/tasks",
json={
"title": "Agent task",
"assignee_type": "agent",
"assignee_id": agent_id,
},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201
data = resp.json()
assert data["assignee_type"] == "agent"
assert data["assignee_id"] == agent_id
async def test_create_task_with_group_assignee(self, tasks_client: AsyncClient, db_session):
"""POST /tasks with assignee_type=group stores assignee_id."""
await seed_tenant_and_users(db_session)
await login_client(tasks_client, "admin@tenanta.com")
group_id = str(uuid.uuid4())
resp = await tasks_client.post(
"/api/v1/tasks",
json={
"title": "Group task",
"assignee_type": "group",
"assignee_id": group_id,
},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201
data = resp.json()
assert data["assignee_type"] == "group"
assert data["assignee_id"] == group_id
async def test_assign_task_polymorphic(self, tasks_client: AsyncClient, db_session):
"""POST /tasks/{id}/assign with assignee_type=agent."""
await seed_tenant_and_users(db_session)
await login_client(tasks_client, "admin@tenanta.com")
created = await tasks_client.post(
"/api/v1/tasks",
json={"title": "Assign me"},
headers=ORIGIN_HEADER,
)
task_id = created.json()["id"]
agent_id = str(uuid.uuid4())
resp = await tasks_client.post(
f"/api/v1/tasks/{task_id}/assign",
json={"assignee_type": "agent", "assignee_id": agent_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
data = resp.json()
assert data["assignee_type"] == "agent"
assert data["assignee_id"] == agent_id
@pytest.mark.asyncio
class TestEntityLinks:
"""Polymorphic entity links (entity_type + entity_id)."""
async def test_create_task_with_entity_link(self, tasks_client: AsyncClient, db_session):
"""POST /tasks with entity_type=company stores entity_id."""
await seed_tenant_and_users(db_session)
await login_client(tasks_client, "admin@tenanta.com")
company_id = str(uuid.uuid4())
resp = await tasks_client.post(
"/api/v1/tasks",
json={
"title": "Company task",
"entity_type": "company",
"entity_id": company_id,
},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201
data = resp.json()
assert data["entity_type"] == "company"
assert data["entity_id"] == company_id
async def test_filter_tasks_by_entity(self, tasks_client: AsyncClient, db_session):
"""GET /tasks?entity_type=&entity_id= filters by entity."""
await seed_tenant_and_users(db_session)
await login_client(tasks_client, "admin@tenanta.com")
company_id = str(uuid.uuid4())
await tasks_client.post(
"/api/v1/tasks",
json={"title": "Company task", "entity_type": "company", "entity_id": company_id},
headers=ORIGIN_HEADER,
)
await tasks_client.post(
"/api/v1/tasks",
json={"title": "Other task"},
headers=ORIGIN_HEADER,
)
resp = await tasks_client.get(
f"/api/v1/tasks?entity_type=company&entity_id={company_id}",
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
items = resp.json()["items"]
assert len(items) == 1
assert items[0]["entity_type"] == "company"
assert items[0]["entity_id"] == company_id
async def test_legacy_contact_id_mirrors_entity(self, tasks_client: AsyncClient, db_session):
"""POST /tasks with contact_id sets entity_type='contact'."""
await seed_tenant_and_users(db_session)
await login_client(tasks_client, "admin@tenanta.com")
contact_id = str(uuid.uuid4())
resp = await tasks_client.post(
"/api/v1/tasks",
json={"title": "Contact task", "contact_id": contact_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201
data = resp.json()
assert data["contact_id"] == contact_id
assert data["entity_type"] == "contact"
assert data["entity_id"] == contact_id
@pytest.mark.asyncio
class TestSubtasks:
"""Subtasks (parent_task_id self-reference)."""
async def test_create_subtask(self, tasks_client: AsyncClient, db_session):
"""POST /tasks/{id}/subtasks creates a subtask."""
await seed_tenant_and_users(db_session)
await login_client(tasks_client, "admin@tenanta.com")
parent = await tasks_client.post(
"/api/v1/tasks",
json={"title": "Parent"},
headers=ORIGIN_HEADER,
)
parent_id = parent.json()["id"]
resp = await tasks_client.post(
f"/api/v1/tasks/{parent_id}/subtasks",
json={"title": "Child"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201
data = resp.json()
assert data["parent_task_id"] == parent_id
async def test_list_subtasks(self, tasks_client: AsyncClient, db_session):
"""GET /tasks/{id}/subtasks lists children."""
await seed_tenant_and_users(db_session)
await login_client(tasks_client, "admin@tenanta.com")
parent = await tasks_client.post(
"/api/v1/tasks",
json={"title": "Parent"},
headers=ORIGIN_HEADER,
)
parent_id = parent.json()["id"]
await tasks_client.post(
f"/api/v1/tasks/{parent_id}/subtasks",
json={"title": "Child 1"},
headers=ORIGIN_HEADER,
)
await tasks_client.post(
f"/api/v1/tasks/{parent_id}/subtasks",
json={"title": "Child 2"},
headers=ORIGIN_HEADER,
)
resp = await tasks_client.get(
f"/api/v1/tasks/{parent_id}/subtasks",
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert len(resp.json()) == 2
@pytest.mark.asyncio
class TestDependencies:
"""Task dependencies (depends_on)."""
async def test_add_and_remove_dependency(self, tasks_client: AsyncClient, db_session):
"""POST/DELETE /tasks/{id}/dependencies."""
await seed_tenant_and_users(db_session)
await login_client(tasks_client, "admin@tenanta.com")
t1 = await tasks_client.post("/api/v1/tasks", json={"title": "Task 1"}, headers=ORIGIN_HEADER)
t2 = await tasks_client.post("/api/v1/tasks", json={"title": "Task 2"}, headers=ORIGIN_HEADER)
t1_id, t2_id = t1.json()["id"], t2.json()["id"]
resp = await tasks_client.post(
f"/api/v1/tasks/{t1_id}/dependencies",
json={"depends_on": t2_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert t2_id in resp.json()["depends_on"]
resp = await tasks_client.delete(
f"/api/v1/tasks/{t1_id}/dependencies/{t2_id}",
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert t2_id not in resp.json()["depends_on"]
@pytest.mark.asyncio
class TestAgentTaskCreation:
"""Agent task creation (task_type='agent_subtask')."""
async def test_create_agent_subtask(self, tasks_client: AsyncClient, db_session):
"""POST /tasks with task_type=agent_subtask."""
await seed_tenant_and_users(db_session)
await login_client(tasks_client, "admin@tenanta.com")
resp = await tasks_client.post(
"/api/v1/tasks",
json={
"title": "Agent subtask",
"task_type": "agent_subtask",
"assignee_type": "agent",
"assignee_id": str(uuid.uuid4()),
},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201
data = resp.json()
assert data["task_type"] == "agent_subtask"
assert data["assignee_type"] == "agent"
@pytest.mark.asyncio
class TestGoalDecomposition:
"""Goal decomposition into milestones/todos."""
async def test_decompose_goal(self, tasks_client: AsyncClient, db_session):
"""POST /tasks/{id}/decompose creates subtasks."""
await seed_tenant_and_users(db_session)
await login_client(tasks_client, "admin@tenanta.com")
goal = await tasks_client.post(
"/api/v1/tasks",
json={"title": "Big Goal", "task_type": "goal"},
headers=ORIGIN_HEADER,
)
goal_id = goal.json()["id"]
resp = await tasks_client.post(
f"/api/v1/tasks/{goal_id}/decompose",
json=[
{"title": "Milestone 1", "milestone": True},
{"title": "Todo 1"},
],
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
data = resp.json()
assert data["goal"]["task_type"] == "goal"
assert len(data["subtasks"]) == 2
types = {s["task_type"] for s in data["subtasks"]}
assert "milestone" in types
assert "todo" in types
@pytest.mark.asyncio
class TestProgressAggregation:
"""Parent progress aggregated from child task status."""
async def test_progress_aggregates_from_children(self, tasks_client: AsyncClient, db_session):
"""Parent progress = % of done children."""
await seed_tenant_and_users(db_session)
await login_client(tasks_client, "admin@tenanta.com")
parent = await tasks_client.post(
"/api/v1/tasks",
json={"title": "Parent", "task_type": "goal"},
headers=ORIGIN_HEADER,
)
parent_id = parent.json()["id"]
c1 = await tasks_client.post(
f"/api/v1/tasks/{parent_id}/subtasks",
json={"title": "Child 1"},
headers=ORIGIN_HEADER,
)
c2 = await tasks_client.post(
f"/api/v1/tasks/{parent_id}/subtasks",
json={"title": "Child 2"},
headers=ORIGIN_HEADER,
)
# Mark one child done → parent progress 50%
await tasks_client.post(
f"/api/v1/tasks/{c1.json()['id']}/status",
json={"status": "done"},
headers=ORIGIN_HEADER,
)
parent_resp = await tasks_client.get(f"/api/v1/tasks/{parent_id}", headers=ORIGIN_HEADER)
assert parent_resp.json()["progress"] == 50
# Mark second child done → parent progress 100%
await tasks_client.post(
f"/api/v1/tasks/{c2.json()['id']}/status",
json={"status": "done"},
headers=ORIGIN_HEADER,
)
parent_resp = await tasks_client.get(f"/api/v1/tasks/{parent_id}", headers=ORIGIN_HEADER)
assert parent_resp.json()["progress"] == 100
async def test_parent_status_propagates_to_review(self, tasks_client: AsyncClient, db_session):
"""All children done → parent auto review."""
await seed_tenant_and_users(db_session)
await login_client(tasks_client, "admin@tenanta.com")
parent = await tasks_client.post(
"/api/v1/tasks",
json={"title": "Parent", "task_type": "goal"},
headers=ORIGIN_HEADER,
)
parent_id = parent.json()["id"]
c1 = await tasks_client.post(
f"/api/v1/tasks/{parent_id}/subtasks",
json={"title": "Child 1"},
headers=ORIGIN_HEADER,
)
await tasks_client.post(
f"/api/v1/tasks/{c1.json()['id']}/status",
json={"status": "done"},
headers=ORIGIN_HEADER,
)
parent_resp = await tasks_client.get(f"/api/v1/tasks/{parent_id}", headers=ORIGIN_HEADER)
assert parent_resp.json()["status"] == "review"
@pytest.mark.asyncio
class TestSuccessCriteria:
"""Success criteria evaluation for goals."""
async def test_goal_done_when_criteria_met(self, tasks_client: AsyncClient, db_session):
"""Goal with all_done criteria becomes done at 100% progress."""
await seed_tenant_and_users(db_session)
await login_client(tasks_client, "admin@tenanta.com")
goal = await tasks_client.post(
"/api/v1/tasks",
json={
"title": "Goal",
"task_type": "goal",
"success_criteria": {"all_done": True},
},
headers=ORIGIN_HEADER,
)
goal_id = goal.json()["id"]
c1 = await tasks_client.post(
f"/api/v1/tasks/{goal_id}/subtasks",
json={"title": "Child 1"},
headers=ORIGIN_HEADER,
)
await tasks_client.post(
f"/api/v1/tasks/{c1.json()['id']}/status",
json={"status": "done"},
headers=ORIGIN_HEADER,
)
goal_resp = await tasks_client.get(f"/api/v1/tasks/{goal_id}", headers=ORIGIN_HEADER)
assert goal_resp.json()["status"] == "done"
@pytest.mark.asyncio
class TestLifecycleStatuses:
"""New lifecycle statuses."""
async def test_all_statuses_accepted(self, tasks_client: AsyncClient, db_session):
"""All lifecycle statuses are accepted by the API."""
await seed_tenant_and_users(db_session)
await login_client(tasks_client, "admin@tenanta.com")
task = await tasks_client.post(
"/api/v1/tasks",
json={"title": "Status task"},
headers=ORIGIN_HEADER,
)
task_id = task.json()["id"]
for status in ["open", "in_progress", "review", "blocked", "done", "cancelled"]:
resp = await tasks_client.post(
f"/api/v1/tasks/{task_id}/status",
json={"status": status},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert resp.json()["status"] == status
async def test_invalid_status_rejected(self, tasks_client: AsyncClient, db_session):
"""Invalid status returns 422."""
await seed_tenant_and_users(db_session)
await login_client(tasks_client, "admin@tenanta.com")
task = await tasks_client.post(
"/api/v1/tasks",
json={"title": "Status task"},
headers=ORIGIN_HEADER,
)
task_id = task.json()["id"]
resp = await tasks_client.post(
f"/api/v1/tasks/{task_id}/status",
json={"status": "invalid"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 422