from __future__ import annotations import json import os import unittest from unittest.mock import MagicMock, patch from cognitive_agent.application.self_extension import ( ExtensionIntegrationError, MAX_PLAN_DELAY_MS, MAX_TOTAL_PLAN_DELAY_MS, SelfExtensionService, SimulationGateway, ) class SelfExtensionDelayPlanTests(unittest.TestCase): def setUp(self) -> None: self.service = SelfExtensionService(SimulationGateway()) @staticmethod def _vehicle(command: str) -> dict[str, object]: return { "kind": "vehicle", "capability_id": "", "command": command, "label": command, "delay_ms": 0, } @staticmethod def _delay(value: object) -> dict[str, object]: return { "kind": "delay", "capability_id": "", "command": "", "label": "wait", "delay_ms": value, } def _plan( self, steps: list[object], *, peripherals: list[dict[str, object]] | None = None, ) -> dict[str, object]: proposed = { "summary": "test plan", "needs_confirmation": False, "question": "", "steps": steps, } vehicle_commands = [ str(step.get("command")) for step in steps if isinstance(step, dict) and step.get("kind") == "vehicle" and step.get("command") ] with patch.dict(os.environ, {"OPENAI_API_KEY": "offline-test-key"}, clear=False): with patch.object( self.service, "_request_gpt_plan", return_value=json.dumps(proposed, ensure_ascii=False), ): return self.service.plan( { "utterance": "test utterance", "peripherals": peripherals or [], "vehicle_devices": [ { "name": "test vehicle device", "actions": [ {"name": command, "command": command} for command in vehicle_commands ], } ], } ) def test_preserves_action_delay_action_order_and_canonicalizes_label(self) -> None: peripherals = [ { "id": "cactus-on", "status": "learned", "device_name": "跳舞仙人掌", "action": "开启", "risk": "low", }, { "id": "cactus-off", "status": "learned", "device_name": "跳舞仙人掌", "action": "关闭", "risk": "low", }, ] steps = [ { "kind": "peripheral", "capability_id": "cactus-on", "command": "", "label": "ignored", "delay_ms": 0, }, self._delay(5_000), { "kind": "peripheral", "capability_id": "cactus-off", "command": "", "label": "ignored", "delay_ms": 0, }, ] result = self._plan(steps, peripherals=peripherals) self.assertEqual( [step["kind"] for step in result["steps"]], ["peripheral", "delay", "peripheral"], ) self.assertEqual(result["steps"][1]["delay_ms"], 5_000) self.assertEqual(result["steps"][1]["label"], "等待 5 秒") self.assertEqual(result["steps"][1]["risk"], "low") def test_rejects_non_integer_or_out_of_range_delay_values(self) -> None: invalid_values: tuple[object, ...] = ( "5000", True, False, 1.5, None, 0, -1, MAX_PLAN_DELAY_MS + 1, ) for value in invalid_values: with self.subTest(value=value): with self.assertRaises(ExtensionIntegrationError): self._plan( [ self._vehicle("light ambient on"), self._delay(value), self._vehicle("light ambient off"), ] ) def test_rejects_delay_at_plan_boundaries_or_next_to_another_delay(self) -> None: invalid_plans = ( [self._delay(1), self._vehicle("ac on")], [self._vehicle("ac on"), self._delay(1)], [ self._vehicle("ac on"), self._delay(1), self._delay(1), self._vehicle("ac off"), ], ) for steps in invalid_plans: with self.subTest(steps=steps): with self.assertRaises(ExtensionIntegrationError): self._plan(steps) def test_rejects_delay_that_becomes_boundary_after_invalid_action_is_removed(self) -> None: with self.assertRaisesRegex(ExtensionIntegrationError, "两个可执行动作之间"): self._plan( [ { "kind": "peripheral", "capability_id": "not-learned", "command": "", "label": "invalid", "delay_ms": 0, }, self._delay(1_000), self._vehicle("ac off"), ] ) def test_enforces_cumulative_delay_limit(self) -> None: accepted = self._plan( [ self._vehicle("ac on"), self._delay(MAX_PLAN_DELAY_MS), self._vehicle("ac temp driver 24"), self._delay(MAX_PLAN_DELAY_MS), self._vehicle("ac off"), ] ) self.assertEqual( sum( step.get("delay_ms", 0) for step in accepted["steps"] if step["kind"] == "delay" ), MAX_TOTAL_PLAN_DELAY_MS, ) with self.assertRaisesRegex(ExtensionIntegrationError, "累计延时"): self._plan( [ self._vehicle("ac on"), self._delay(MAX_PLAN_DELAY_MS), self._vehicle("ac temp driver 24"), self._delay(MAX_PLAN_DELAY_MS), self._vehicle("ac fan 3"), self._delay(1), self._vehicle("ac off"), ] ) def test_rejects_device_fields_on_delay_step(self) -> None: for field_name, value in (("command", "wait 5 seconds"), ("capability_id", "cap-1")): with self.subTest(field_name=field_name): delay_step = self._delay(5_000) delay_step[field_name] = value with self.assertRaisesRegex(ExtensionIntegrationError, "不能包含"): self._plan( [ self._vehicle("ac on"), delay_step, self._vehicle("ac off"), ] ) def test_rejects_implicit_or_unknown_delay_representation(self) -> None: non_delay_with_wait = self._vehicle("ac on") non_delay_with_wait["delay_ms"] = 5_000 with self.assertRaisesRegex(ExtensionIntegrationError, "非延时步骤"): self._plan([non_delay_with_wait, self._vehicle("ac off")]) with self.assertRaisesRegex(ExtensionIntegrationError, "未知步骤类型"): self._plan( [ self._vehicle("ac on"), { "kind": "wait", "capability_id": "", "command": "", "label": "wait", "delay_ms": 5_000, }, self._vehicle("ac off"), ] ) def test_gpt_schema_and_prompt_define_millisecond_delay_contract(self) -> None: response = MagicMock() response.__enter__.return_value = response response.read.return_value = json.dumps({"output_text": "{}"}).encode("utf-8") with patch.object(self.service, "openai_streaming", False): with patch( "cognitive_agent.application.self_extension.urlopen", return_value=response, ) as open_request: self.service._request_gpt_plan( "开启跳舞仙人掌,5s 后关闭", [], [], "test-key" ) request_payload = json.loads(open_request.call_args.args[0].data.decode("utf-8")) plan_format = request_payload["text"]["format"] step_schema = plan_format["schema"]["properties"]["steps"]["items"] self.assertTrue(plan_format["strict"]) self.assertIn("delay", step_schema["properties"]["kind"]["enum"]) self.assertIn("delay_ms", step_schema["required"]) self.assertEqual( step_schema["properties"]["delay_ms"]["maximum"], MAX_PLAN_DELAY_MS, ) prompt = "\n".join( content["text"] for message in request_payload["input"] if message.get("role") == "system" for content in message.get("content", []) if content.get("type") == "input_text" ) self.assertIn("delay_ms=5000", prompt) self.assertIn(str(MAX_TOTAL_PLAN_DELAY_MS), prompt) self.assertIn("不能位于首尾", prompt) self.assertIn("绝不能把等待时间写进 command", prompt) if __name__ == "__main__": unittest.main()