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()