463 lines
18 KiB
Python
463 lines
18 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_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()
|