PMFlow/backend/tests/test_openclaw_commander_ser...

471 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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_extract_project_create_title_handles_requirement_prefix(self) -> None:
title = OpenClawCommanderService._extract_project_create_title("给我新增个项目,需求名字为openclaw自动化部署。")
self.assertEqual(title, "openclaw自动化部署")
def test_extract_project_create_title_handles_project_name_prefix(self) -> None:
title = OpenClawCommanderService._extract_project_create_title("给我新增个项目,项目名为openclaw自动化部署。")
self.assertEqual(title, "openclaw自动化部署")
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_create_fallback_supports_add_project_phrase(self) -> None:
commander = _ProjectFallbackCommander()
plan = self.async_run(
commander._build_fallback_plan(
tenant_id=1,
project_id=None,
instruction="给我新增个项目,openclaw自动化部署。",
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"], "openclaw自动化部署")
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()