PMFlow/backend/tests/test_openclaw_commander_ser...

446 lines
17 KiB
Python

import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from app.models import Project
from app.services.openclaw_commander_service import (
OpenClawCommanderService,
OperationExecutorService,
)
from app.services.llm import StructuredExtractionResult
class _DuplicatePersistentExecutor(OperationExecutorService):
def __init__(self) -> None:
super().__init__(db=None) # type: ignore[arg-type]
async def _claim_persistent_idempotency(self, **_: object):
return None, {
"status": "skipped",
"message": "duplicate_persistent",
"entity_type": "notification",
"entity_id": "21",
"trace_id": "openclaw-dup-1",
}
async def _execute_single_operation(self, **_: object):
raise AssertionError("duplicate notification should not execute")
class _SignalSourceFallbackCommander(OpenClawCommanderService):
def __init__(self, *, charter_ready: bool) -> None:
super().__init__(db=None) # type: ignore[arg-type]
self.charter_ready = charter_ready
async def _load_project(self, **_: object):
return SimpleNamespace(id=3, owner_id=7, title="项目A", status="active")
async def _load_latest_aligned_charter(self, **_: object):
if not self.charter_ready:
return None
return SimpleNamespace(id=2, status="aligned")
async def _resolve_signal_source_notification_recipient(self, **_: object):
return 7
class _ProjectFallbackCommander(OpenClawCommanderService):
def __init__(self) -> None:
super().__init__(db=None) # type: ignore[arg-type]
async def _load_project_directory(self, **_: object):
return [{"project_id": 8, "title": "财务冲账协同", "status": "active"}]
class _MemoryDB:
def __init__(self) -> None:
self.added: list[object] = []
def add(self, obj: object) -> None:
self.added.append(obj)
async def flush(self) -> None:
for obj in self.added:
if isinstance(obj, Project) and getattr(obj, "id", None) in {None, 0}:
obj.id = 8
async def commit(self) -> None:
return None
async def rollback(self) -> None:
return None
class _ProjectExecutor(OperationExecutorService):
def __init__(self, *, existing_project: object | None = None) -> None:
self.memory_db = _MemoryDB()
super().__init__(db=self.memory_db) # type: ignore[arg-type]
self.existing_project = existing_project
async def _load_project_by_title(self, **_: object):
return self.existing_project
async def _load_active_user(self, *, tenant_id: int, user_id: int):
_ = tenant_id
return SimpleNamespace(id=user_id, status="active")
class OpenClawCommanderServiceTest(unittest.TestCase):
def setUp(self) -> None:
self.executor = OperationExecutorService(db=None) # type: ignore[arg-type]
def test_plan_operations_accepts_tool_call_json(self) -> None:
service = OpenClawCommanderService(db=None) # type: ignore[arg-type]
service._commander_operation_catalog = lambda: [ # type: ignore[method-assign]
{
"operation_code": "task.assign",
"description": "assign",
"input_schema": {"properties": {"task_id": {}, "assignee_id": {}}},
}
]
with patch(
"app.services.openclaw_commander_service.llm_service.generate_structured_object",
AsyncMock(
return_value=StructuredExtractionResult(
payload={
"summary": "先补负责人,再发通知",
"confidence": 0.82,
"operations": [
{
"operation_code": "task.assign",
"payload": {"task_id": 12, "assignee_id": 5},
"reason": "技能更匹配",
}
],
},
source_field="tool_calls.function.arguments",
provider="glm",
model="GLM-4.7",
validation="ok",
)
),
):
plan, planner_status, diagnostics = self.async_run(
service._plan_operations(
tenant_id=1,
operator_user=SimpleNamespace(id=9, name="Boss", role="BOSS"),
project=SimpleNamespace(id=3, title="项目A", name="项目A", description="desc", status="active", assigned_role=""),
tasks=[],
task_summary={},
members=[],
instruction="把任务 12 分给张三",
dry_run=False,
max_operations=4,
response_mode="fast",
)
)
self.assertEqual(planner_status, "planned")
self.assertEqual(diagnostics["source_field"], "tool_calls.function.arguments")
self.assertEqual(plan["operations"][0]["payload"]["task_id"], 12)
def test_plan_operations_returns_schema_invalid_diagnostics_on_bad_payload(self) -> None:
service = OpenClawCommanderService(db=None) # type: ignore[arg-type]
service._commander_operation_catalog = lambda: [] # type: ignore[method-assign]
service._build_fallback_plan = AsyncMock(return_value={"summary": "等待补充上下文。", "confidence": 0.0, "operations": []}) # type: ignore[method-assign]
with patch(
"app.services.openclaw_commander_service.llm_service.generate_structured_object",
AsyncMock(
return_value=StructuredExtractionResult(
payload=None,
source_field="message.content",
provider="glm",
model="GLM-4.7",
error_code="structured_schema_invalid",
validation="invalid",
)
),
):
plan, planner_status, diagnostics = self.async_run(
service._plan_operations(
tenant_id=1,
operator_user=SimpleNamespace(id=9, name="Boss", role="BOSS"),
project=SimpleNamespace(id=3, title="项目A", name="项目A", description="desc", status="active", assigned_role=""),
tasks=[],
task_summary={},
members=[],
instruction="把任务 12 分给张三",
dry_run=False,
max_operations=4,
response_mode="fast",
)
)
self.assertEqual(planner_status, "fallback")
self.assertEqual(diagnostics["error_code"], "structured_schema_invalid")
self.assertEqual(plan["operations"], [])
def test_validate_payload_coerces_integer_and_rejects_extra_fields(self) -> None:
normalized = self.executor._validate_and_normalize_payload(
operation_code="task.assign",
payload={"task_id": "12", "assignee_id": "8"},
)
self.assertEqual(normalized, {"task_id": 12, "assignee_id": 8})
with self.assertRaisesRegex(ValueError, "unsupported fields"):
self.executor._validate_and_normalize_payload(
operation_code="task.assign",
payload={"task_id": 12, "assignee_id": 8, "foo": "bar"},
)
def test_validate_task_create_payload_supports_array_and_numeric_fields(self) -> None:
normalized = self.executor._validate_and_normalize_payload(
operation_code="task.create",
payload={
"project_id": "7",
"title": "补充告警兜底",
"required_skill_tag_ids": ["1", 2, "3"],
"estimated_hours": "4.5",
"priority": "P0",
},
)
self.assertEqual(normalized["project_id"], 7)
self.assertEqual(normalized["required_skill_tag_ids"], [1, 2, 3])
self.assertEqual(normalized["estimated_hours"], 4.5)
self.assertEqual(normalized["priority"], "P0")
def test_validate_project_create_payload_supports_optional_owner_and_description(self) -> None:
normalized = self.executor._validate_and_normalize_payload(
operation_code="project.create",
payload={
"title": "财务冲账协同",
"description": "用于协调财务冲账任务。",
"owner_id": "7",
},
)
self.assertEqual(
normalized,
{
"title": "财务冲账协同",
"description": "用于协调财务冲账任务。",
"owner_id": 7,
},
)
def test_workload_update_payload_requires_max_weekly_hours(self) -> None:
normalized = self.executor._validate_and_normalize_payload(
operation_code="personnel.workload_update",
payload={"user_id": "5", "max_weekly_hours": "32"},
)
self.assertEqual(normalized, {"user_id": 5, "max_weekly_hours": 32.0})
with self.assertRaisesRegex(ValueError, "unsupported fields"):
self.executor._validate_and_normalize_payload(
operation_code="personnel.workload_update",
payload={"user_id": 5, "workload": 70},
)
def test_notification_idempotency_key_uses_trimmed_content_hash(self) -> None:
base_payload = {
"user_id": 3,
"title": "请处理阻塞",
"content": " 任务 A 已重新分配给你 ",
}
same_after_trim = {
"user_id": 3,
"title": "请处理阻塞",
"content": "任务 A 已重新分配给你",
}
changed_content = {
"user_id": 3,
"title": "请处理阻塞",
"content": "任务 B 已重新分配给你",
}
key_a = self.executor._build_idempotency_key(
operation_code="notification.create",
payload=base_payload,
tenant_id=1,
project_id=9,
)
key_b = self.executor._build_idempotency_key(
operation_code="notification.create",
payload=same_after_trim,
tenant_id=1,
project_id=9,
)
key_c = self.executor._build_idempotency_key(
operation_code="notification.create",
payload=changed_content,
tenant_id=1,
project_id=9,
)
key_other_project = self.executor._build_idempotency_key(
operation_code="notification.create",
payload=same_after_trim,
tenant_id=1,
project_id=10,
)
self.assertEqual(key_a, key_b)
self.assertNotEqual(key_a, key_c)
self.assertNotEqual(key_a, key_other_project)
def test_execute_operations_skips_persistent_duplicate_notification(self) -> None:
executor = _DuplicatePersistentExecutor()
result = self.async_run(
executor.execute_operations(
tenant_id=1,
operator_user=SimpleNamespace(id=9),
project=SimpleNamespace(id=4),
operations=[
{
"operation_code": "notification.create",
"payload": {
"user_id": 3,
"title": "请处理阻塞",
"content": "任务 A 已重新分配给你",
},
"reason": "重复回放",
}
],
dry_run=False,
trace_id="openclaw-1",
)
)
self.assertEqual(result["requested_count"], 1)
self.assertEqual(result["skipped_count"], 1)
self.assertEqual(result["results"][0]["message"], "duplicate_persistent")
self.assertEqual(result["results"][0]["entity_id"], "21")
def test_normalize_task_priority_maps_p_levels(self) -> None:
self.assertEqual(self.executor._normalize_task_priority("P0"), "high")
self.assertEqual(self.executor._normalize_task_priority("P1"), "medium")
self.assertEqual(self.executor._normalize_task_priority("P3"), "low")
def test_signal_source_fallback_without_charter_creates_notification_only(self) -> None:
commander = _SignalSourceFallbackCommander(charter_ready=False)
plan = self.async_run(
commander._build_fallback_plan(
tenant_id=1,
project_id=3,
instruction="新增一个 GitHub 信号源,监控核心仓库 issue。",
max_operations=4,
error="llm offline",
)
)
self.assertEqual(plan["summary"], "OpenClaw 规划失败,已回退为信号源接入最小闭环。")
self.assertEqual(len(plan["operations"]), 1)
self.assertEqual(plan["operations"][0]["operation_code"], "notification.create")
self.assertEqual(plan["operations"][0]["payload"]["title"], "GitHub信号源接入待确认")
def test_signal_source_fallback_with_charter_creates_task_and_notification(self) -> None:
commander = _SignalSourceFallbackCommander(charter_ready=True)
plan = self.async_run(
commander._build_fallback_plan(
tenant_id=1,
project_id=3,
instruction="新增一个 GitHub 信号源,监控核心仓库 issue。",
max_operations=4,
error="llm offline",
)
)
self.assertEqual([item["operation_code"] for item in plan["operations"]], ["task.create", "notification.create"])
self.assertEqual(plan["operations"][0]["payload"]["project_id"], 3)
self.assertEqual(plan["operations"][0]["payload"]["title"], "接入GitHub信号源")
def test_project_create_fallback_builds_project_create_operation(self) -> None:
commander = _ProjectFallbackCommander()
plan = self.async_run(
commander._build_fallback_plan(
tenant_id=1,
project_id=None,
instruction="帮我创建一个项目,财务冲账协同",
max_operations=4,
error="structured_http_error",
project_directory=None,
)
)
self.assertEqual(plan["operations"][0]["operation_code"], "project.create")
self.assertEqual(plan["operations"][0]["payload"]["title"], "财务冲账协同")
def test_project_status_fallback_resolves_project_id_from_directory(self) -> None:
commander = _ProjectFallbackCommander()
plan = self.async_run(
commander._build_fallback_plan(
tenant_id=1,
project_id=None,
instruction="把财务冲账协同项目归档",
max_operations=4,
error="structured_http_error",
project_directory=None,
)
)
self.assertEqual(plan["operations"][0]["operation_code"], "project.archive")
self.assertEqual(plan["operations"][0]["payload"]["project_id"], 8)
def test_execute_project_create_creates_active_project(self) -> None:
executor = _ProjectExecutor()
with patch(
"app.services.openclaw_commander_service.log_operation",
AsyncMock(),
), patch(
"app.services.openclaw_commander_service.context_cache_service.invalidate_system_context",
AsyncMock(),
):
result = self.async_run(
executor._execute_project_create(
tenant_id=1,
operator_user=SimpleNamespace(id=9, role="BOSS"),
payload={"title": "财务冲账协同", "description": "用于财务冲账。"},
reason="explicit_project_create",
trace_id="openclaw-1",
)
)
created_project = next(obj for obj in executor.memory_db.added if isinstance(obj, Project))
self.assertEqual(result["status"], "applied")
self.assertEqual(created_project.title, "财务冲账协同")
self.assertEqual(created_project.status, "active")
self.assertEqual(result["result"]["project_id"], 8)
def test_execute_project_create_skips_existing_project(self) -> None:
executor = _ProjectExecutor(
existing_project=SimpleNamespace(id=5, title="财务冲账协同", name="财务冲账协同", status="active")
)
result = self.async_run(
executor._execute_project_create(
tenant_id=1,
operator_user=SimpleNamespace(id=9, role="BOSS"),
payload={"title": "财务冲账协同"},
reason="explicit_project_create",
trace_id="openclaw-1",
)
)
self.assertEqual(result["status"], "skipped")
self.assertEqual(result["entity_id"], "5")
self.assertEqual(result["result"]["status"], "active")
def async_run(self, awaitable):
import asyncio
return asyncio.run(awaitable)
if __name__ == "__main__":
unittest.main()