diff --git a/apps/web-console/assistant-chat.js b/apps/web-console/assistant-chat.js index d2dc86a..4ef225e 100644 --- a/apps/web-console/assistant-chat.js +++ b/apps/web-console/assistant-chat.js @@ -168,11 +168,11 @@ if (!compact || !context?.name) return false; const politePrefix = "(?:(?:请|麻烦)(?:帮我|帮忙|给我)?|帮我|帮忙|给我)?"; const prefixUpdateVerb = "(?:修改一下|修改|调整|更改|更新|改一下|变更)"; - const changePredicate = "(?:修改|改成|改为|更改|更新|变更|变为|换成|调整|调节|调高|调低|调到|调成|调为|调至|设置|设为|设成|设到|设至|打开|开启|启动|关闭|关掉|关上|全关|停止|停用|切换|进入|退出|移动|前移|后退|前进|升高|降低|抬高|放低|降下|升起|调直|放倒|复位|半开|开到|关到|拉开|拉上|上锁|解锁)"; + const changePredicate = "(?:修改|改成|改为|更改|更新|变更|变为|变成|换成|调整|调节|调高|调低|调到|调成|调为|调至|设置|设为|设成|设到|设至|打开|开启|启动|关闭|关掉|关上|全关|停止|停用|切换|进入|退出|移动|前移|后退|前进|升高|降低|抬高|放低|降下|升起|调直|放倒|复位|半开|开到|关到|拉开|拉上|上锁|解锁)"; const modeName = "([^,。!?!?、;;::]{1,64}?模式)"; const modePossessiveBoundary = "(?:中的|里的|内的|之中的|的|中|里|内)"; const modeMemberBoundary = `(?:${modePossessiveBoundary}|[,,::])`; - const question = /为什么|什么|怎么|如何|介绍|解释|作用|区别|原理|哪些|多少|几(?:度|档|级|个|项)?|是否|能否|可不可以|合适吗|合理吗|会怎样|列出|告诉我|[??]|吗[。.!!]?$/; + const question = /为什么|什么|怎么|如何|介绍|解释|作用|区别|原理|哪些|多少|几(?:度|档|级|个|项)?|是否|能否|可不可以|合适吗|合理吗|会怎样|列出|告诉我|[??]|(?:吗|么|呢)[。.!!]?$/; const candidates = [ { pattern: new RegExp(`^${politePrefix}${prefixUpdateVerb}${modeName}${modeMemberBoundary}[,,::]?(.*)$`), @@ -469,6 +469,18 @@ on: "开启", off: "关闭", }[String(value)] || String(value || "已配置")); + const shadePositionLabel = (value) => { + if (value == null || String(value).trim() === "") return "目标开度待确认"; + const position = Number(value); + if (!Number.isFinite(position) || position < 0 || position > 100) { + return "目标开度待确认"; + } + const target = `目标开度 ${position}%`; + if (position === 0) return `完全关闭(${target})`; + if (position === 50) return `半开(${target})`; + if (position === 100) return `完全打开(${target})`; + return target; + }; actions.forEach((action, index) => { if (consumed.has(index)) return; const capability = String(action.capability || ""); @@ -482,7 +494,9 @@ if (capability === "roof.set_shade_motion" || capability === "roof.set_shade_position") { label = "遮阳帘"; - value = params.position_pct != null ? `${params.position_pct}%` : motionLabel(params.motion); + value = params.position_pct != null + ? shadePositionLabel(params.position_pct) + : motionLabel(params.motion); icon = "blinds"; order = instructionOrder("遮阳帘", "天幕帘"); } else if (domain === "window") { diff --git a/apps/web-console/index.html b/apps/web-console/index.html index 13c53ca..69b964c 100755 --- a/apps/web-console/index.html +++ b/apps/web-console/index.html @@ -13,7 +13,7 @@ - + @@ -26,7 +26,7 @@ - + diff --git a/apps/web-console/service-worker.js b/apps/web-console/service-worker.js index 4e0d693..925e37f 100644 --- a/apps/web-console/service-worker.js +++ b/apps/web-console/service-worker.js @@ -16,8 +16,8 @@ const APP_SHELL = [ "/speech-input.css?v=sensevoice-7", "/speech-input.js?v=sensevoice-12", "/speech-audio-worklet.js?v=sensevoice-5", - "/assistant-chat.css?v=codex-assistant-23", - "/assistant-chat.js?v=codex-assistant-23", + "/assistant-chat.css?v=codex-assistant-24", + "/assistant-chat.js?v=codex-assistant-24", "/energy-monitor.css?v=energy-monitor-4", "/energy-monitor.js?v=energy-monitor-4", "/assets/pets/self-extension-cat.png?v=3", diff --git a/src/cognitive_agent/application/codex_assistant.py b/src/cognitive_agent/application/codex_assistant.py index 71dfa3c..77edb5b 100644 --- a/src/cognitive_agent/application/codex_assistant.py +++ b/src/cognitive_agent/application/codex_assistant.py @@ -983,6 +983,22 @@ class CodexAssistantService: if draft.get("existing_mode_id") else "确认后可保存为功能模式。" ) + requested_operation_count = draft.get("requested_operation_count") + changed_operation_count = draft.get("changed_operation_count") + no_effective_change = ( + isinstance(requested_operation_count, int) + and not isinstance(requested_operation_count, bool) + and requested_operation_count > 0 + and isinstance(changed_operation_count, int) + and not isinstance(changed_operation_count, bool) + and changed_operation_count == 0 + ) + if no_effective_change: + return ( + f"待保存的“{name}”模式中,该项原本已是目标状态," + f"已保持不变并重新校验全部{operation_count}项组合动作。" + f"原草稿未被保存或执行,可以继续调整;{persistence_hint}" + ) return ( f"已调整待保存的“{name}”模式,并重新校验全部" f"{operation_count}项组合动作。原草稿未被保存或执行," @@ -1839,6 +1855,8 @@ class CodexAssistantService: "generation_source", "draft_revision", "revises_source_request_id", + "requested_operation_count", + "changed_operation_count", ) if key in mode_profile_draft } diff --git a/src/cognitive_agent/application/mode_profile_intent.py b/src/cognitive_agent/application/mode_profile_intent.py index 6f8825e..29aa8e9 100644 --- a/src/cognitive_agent/application/mode_profile_intent.py +++ b/src/cognitive_agent/application/mode_profile_intent.py @@ -29,7 +29,7 @@ _CONTEXTUAL_NAME_FIRST_UPDATE = re.compile( r"(?P.+?)[。.!!]?$" ) MODE_PROFILE_CHANGE_PREDICATE = re.compile( - r"修改|改成|改为|更改|更新|变更|变为|换成|" + r"修改|改成|改为|更改|更新|变更|变成|变为|换成|" r"调整|调节|调高|调低|调到|调成|调为|调至|" r"设置|设为|设成|设到|设至|" r"打开|开启|启动|关闭|关掉|关上|全关|停止|停用|切换|进入|退出|" @@ -40,7 +40,7 @@ MODE_PROFILE_QUERY_CUE = re.compile( r"什么|哪些|多少|几(?:度|档|级|个|项)?|是否|是不是|" r"合适(?:吗)?|合理(?:吗)?|会(?:怎么样|怎样|如何)|" r"怎么|如何|列(?:出|一下)|介绍(?:一下)?|说明(?:一下)?|" - r"告诉我|查询|查看|有什么作用|是何作用|还是|[??]" + r"告诉我|查询|查看|有什么作用|是何作用|还是|[??]|(?:吗|么|呢)$" ) diff --git a/src/cognitive_agent/application/native_can_intent.py b/src/cognitive_agent/application/native_can_intent.py index e07df12..3a97a55 100644 --- a/src/cognitive_agent/application/native_can_intent.py +++ b/src/cognitive_agent/application/native_can_intent.py @@ -1349,7 +1349,8 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]: shade_text = _scoped_text(compact, r"遮阳帘|天幕帘") if shade_text and _allows_vehicle_wide_target(shade_text): position = re.search( - r"(?:位置|开度|调整到|调到|设为|设置为|开到|打开(?:到)?|开启(?:到)?)(\d{1,3})%", + r"(?:位置|开度|调整到|调整为|调到|调为|改为|改成|变为|变成|" + r"换成|设为|设置为|开到|打开(?:到)?|开启(?:到)?)(\d{1,3})%", shade_text, ) if position and int(position[1]) <= 100: diff --git a/src/cognitive_agent/application/seat_service.py b/src/cognitive_agent/application/seat_service.py index 95acc4c..3f8a8fe 100755 --- a/src/cognitive_agent/application/seat_service.py +++ b/src/cognitive_agent/application/seat_service.py @@ -4309,7 +4309,7 @@ class SeatDemoService: if not change_text: raise ValueError("请说明要调整的设备和明确目标值。") parseable_change_text = re.sub( - r"改为|改成|变更为|换成|变为", + r"改为|改成|变更为|换成|变成|变为", "调整为", change_text, ) @@ -4387,6 +4387,22 @@ class SeatDemoService: ): item for item in normalized_changes } + base_by_key = { + ( + str(item["domain"]), + str(item["capability"]), + str(item["target_scope"]), + ): item + for item in base_operations + } + requested_operation_count = len(change_by_key) + changed_operation_count = sum( + 1 + for key, item in change_by_key.items() + if key not in base_by_key + or dict(base_by_key[key].get("parameters") or {}) + != dict(item.get("parameters") or {}) + ) merged: list[dict[str, Any]] = [] for operation in base_operations: key = ( @@ -4397,7 +4413,7 @@ class SeatDemoService: merged.append(deepcopy(change_by_key.pop(key, operation))) merged.extend(deepcopy(list(change_by_key.values()))) - return self._process_mode_profile_create( + revised_result = self._process_mode_profile_create( utterance, "local", conversation_id, @@ -4410,6 +4426,26 @@ class SeatDemoService: assistant_plan=self._mode_profile_draft_plan(merged), source_draft=source_draft, ) + revised_draft = revised_result.get("mode_profile_draft") + if ( + revised_result.get("status") == "succeeded" + and isinstance(revised_draft, dict) + and revised_draft.get("generation_source") == "draft_revision" + ): + revised_draft["requested_operation_count"] = ( + requested_operation_count + ) + revised_draft["changed_operation_count"] = changed_operation_count + mode_creation = revised_result.get("mode_creation") + if isinstance(mode_creation, dict): + mode_creation["requested_operation_count"] = ( + requested_operation_count + ) + mode_creation["changed_operation_count"] = ( + changed_operation_count + ) + self._persist(revised_result) + return revised_result except ValueError as exc: result["status"] = "needs_confirmation" result["execution_mode"] = "dry_run" @@ -7180,7 +7216,21 @@ class SeatDemoService: allow_scene_composition: bool = False, ) -> tuple[dict[str, Any], str, str | None]: vehicle_profile = vehicle_profile or self.vehicle_profiles.default() - clause_results = self._local_clause_results(utterance, context) + if assistant_plan is not None and allow_scene_composition: + # ``utterance`` is a synthesized ``进入`` carrier for a + # name-only scene (and for a server-owned draft revision). Words + # inside that label are not user-authored atomic instructions: + # e.g. ``外循环通风模式`` previously leaked ``通风`` into the seat + # parser and added an AMBIGUOUS_INTENT beside an otherwise valid + # climate plan. In this narrow path the complete assistant plan is + # the candidate source of actions; it still passes the scene + # allowlist, registered capability contract, ModelPlanAdapter, + # schema/safety validation and native compiler below. + clause_results = [ + (utterance, self._merge_parsed_results([]), "unparsed") + ] + else: + clause_results = self._local_clause_results(utterance, context) trusted_local = self._merge_parsed_results( [parsed for _, parsed, status in clause_results if status != "unparsed"] diff --git a/tests/e2e/test_assistant_web_api.py b/tests/e2e/test_assistant_web_api.py index 8886ea4..71db6e6 100644 --- a/tests/e2e/test_assistant_web_api.py +++ b/tests/e2e/test_assistant_web_api.py @@ -770,6 +770,37 @@ class TestCodexAssistantWebApi: assert action["payload"]["hardware_execution_allowed"] is False assert events[0]["engine"] == "n7web-mode-builder" + def test_become_percentage_update_keeps_movie_draft_route_end_to_end( + self, + ) -> None: + reference = { + "name": "观影模式", + "source_request_id": "req-mode-preview", + "vehicle_id": "denza_n7", + } + message = "把观影模式的遮阳帘变成100%" + + events = self.post_assistant( + { + "message": message, + "conversation_id": "conv-api-movie-shade-become-percent", + "execution_mode": "hardware", + "mode_profile_draft_context": reference, + } + ) + + assert not FAKE_CODEX.turn_calls + call = AssistantApiHandler.service.calls[-1] + assert call["utterance"] == message + assert call["mode_profile_draft_context"] == reference + action = next(event for event in events if event["type"] == "action.completed") + assert action["success"] is True + assert action["payload"]["execution_mode"] == "dry_run" + assert action["payload"]["hardware_execution_allowed"] is False + assert action["payload"]["mode_profile_draft"]["draft_revision"] == 2 + assert events[0]["route"] == "vehicle_control" + assert events[0]["engine"] == "n7web-mode-builder" + def test_active_draft_reference_is_not_forwarded_for_an_unrelated_question( self, ) -> None: diff --git a/tests/e2e/test_seat_web_api.py b/tests/e2e/test_seat_web_api.py index 88087dd..d0e4a33 100755 --- a/tests/e2e/test_seat_web_api.py +++ b/tests/e2e/test_seat_web_api.py @@ -656,7 +656,7 @@ operations: ) as response: extension_styles = response.read().decode("utf-8").replace("\r\n", "\n") with self.opener.open( - f"{self.base_url}/assistant-chat.js?v=codex-assistant-23", timeout=3 + f"{self.base_url}/assistant-chat.js?v=codex-assistant-24", timeout=3 ) as response: assistant_script = response.read().decode("utf-8") with self.opener.open( @@ -718,8 +718,8 @@ operations: self.assertNotIn("滚动查看更多", html) self.assertIn('aria-label="发送消息"', html) self.assertIn('id="assistantTranscript"', html) - self.assertIn('href="/assistant-chat.css?v=codex-assistant-23"', html) - self.assertIn('src="/assistant-chat.js?v=codex-assistant-23"', html) + self.assertIn('href="/assistant-chat.css?v=codex-assistant-24"', html) + self.assertIn('src="/assistant-chat.js?v=codex-assistant-24"', html) self.assertIn('href="/energy-monitor.css?v=energy-monitor-4"', html) self.assertIn('src="/energy-monitor.js?v=energy-monitor-4"', html) self.assertNotIn('"admin"', console_script) @@ -995,8 +995,8 @@ operations: self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v272"', service_worker) self.assertIn('/console-v2.css?v=delivery-state-65', service_worker) self.assertIn('/console-v2.js?v=delivery-state-62', service_worker) - self.assertIn('/assistant-chat.css?v=codex-assistant-23', service_worker) - self.assertIn('/assistant-chat.js?v=codex-assistant-23', service_worker) + self.assertIn('/assistant-chat.css?v=codex-assistant-24', service_worker) + self.assertIn('/assistant-chat.js?v=codex-assistant-24', service_worker) self.assertIn('/energy-monitor.css?v=energy-monitor-4', service_worker) self.assertIn('/energy-monitor.js?v=energy-monitor-4', service_worker) self.assertIn('"/app.js?v=dual-seat-127"', service_worker) diff --git a/tests/integration/test_mode_draft_parameter_matrix.py b/tests/integration/test_mode_draft_parameter_matrix.py new file mode 100644 index 0000000..1430250 --- /dev/null +++ b/tests/integration/test_mode_draft_parameter_matrix.py @@ -0,0 +1,1230 @@ +from __future__ import annotations + +from copy import deepcopy +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from unittest.mock import patch + +import pytest + +from cognitive_agent.application.assistant_capabilities import AssistantCapabilityCatalog +from cognitive_agent.application.assistant_plan_guard import MAX_PLAN_REQUESTS +from cognitive_agent.application.codex_assistant import _authorize_vehicle_request +from cognitive_agent.application.mode_profile import ModeProfileRegistry +from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService + + +@dataclass(frozen=True) +class SceneCase: + case_id: str + mode_name: str + capability: str + target: str + parameters: dict[str, object] + + @property + def domain(self) -> str: + return self.capability.split(".", 1)[0] + + +@dataclass(frozen=True) +class DraftChangeCase: + case_id: str + message: str + capability: str + target: str + parameters: dict[str, object] + + +def _scene_request( + capability: str, + target: str, + parameters: dict[str, object], + *, + operation: str = "set", + duration_ms: int | None = None, +) -> dict[str, object]: + return { + "domain": capability.split(".", 1)[0], + "capability": capability, + "target_scope": target, + "operation": operation, + "duration_ms": duration_ms, + "parameters": [ + {"name": name, "value": value} + for name, value in parameters.items() + ], + "reason": "按用户场景要求设置当前工程已登记的绝对目标。", + } + + +def _scene_plan(*requests: dict[str, object]) -> dict[str, object]: + return { + "detected_domains": list( + dict.fromkeys(str(request["domain"]) for request in requests) + ) or ["seat"], + "requests": [deepcopy(request) for request in requests], + "ambiguities": [], + "decision_summary": ["只组合当前工程注册表允许的稳定目标。"], + "confidence": 0.95, + } + + +@pytest.fixture() +def mode_service(tmp_path: Path) -> tuple[SeatDemoService, Path]: + service = SeatDemoService(REPOSITORY_ROOT) + service.artifact_root = tmp_path / "artifacts" + user_root = tmp_path / "user-modes" + service.mode_profiles = ModeProfileRegistry( + REPOSITORY_ROOT / "config" / "modes", + user_root=user_root, + ) + return service, user_root + + +def _preview_without_hardware( + service: SeatDemoService, + message: str, + *, + conversation_id: str, + assistant_plan: dict[str, object] | None = None, + draft_context: dict[str, str] | None = None, +) -> dict[str, Any]: + with ( + patch.object( + service.hardware_execution, + "reserve", + side_effect=AssertionError("mode draft route reached a hardware lease"), + ), + patch.object( + service.openai, + "interpret", + side_effect=AssertionError("the deterministic matrix called an external model"), + ), + ): + return service.preview_native_can( + message, + provider="local", + conversation_id=conversation_id, + execution_mode="hardware", + vehicle_id="denza_n7", + assistant_plan=deepcopy(assistant_plan), + mode_profile_draft_context=deepcopy(draft_context), + ) + + +def _intent_actions(result: dict[str, Any]) -> list[dict[str, Any]]: + intent_ir = result.get("intent_ir") + if not isinstance(intent_ir, dict): + return [] + spec = intent_ir.get("spec") + if not isinstance(spec, dict): + return [] + actions = spec.get("actions") + if not isinstance(actions, list): + return [] + return [action for action in actions if isinstance(action, dict)] + + +def _target(action: dict[str, Any]) -> str: + raw = action.get("target") + if isinstance(raw, dict): + return str( + raw.get("instance") + or raw.get("zone") + or raw.get("scope") + or "" + ) + return str(raw or "") + + +def _action_map( + result: dict[str, Any], +) -> dict[tuple[str, str], dict[str, object]]: + return { + (str(action.get("capability") or ""), _target(action)): dict( + action.get("parameters") or {} + ) + for action in _intent_actions(result) + } + + +def _assert_parameter_subset( + result: dict[str, Any], + capability: str, + target: str, + parameters: dict[str, object], +) -> None: + action = _action_map(result).get((capability, target)) + assert action is not None, (capability, target, _action_map(result)) + for name, value in parameters.items(): + assert action.get(name) == value + + +def _draft_reference(result: dict[str, Any]) -> dict[str, str]: + draft = result.get("mode_profile_draft") + assert isinstance(draft, dict) + return { + "name": str(draft["name"]), + "source_request_id": str(draft["source_request_id"]), + "vehicle_id": str(draft["vehicle_id"]), + } + + +def _assert_successful_preview( + result: dict[str, Any], + *, + name: str, + operation_count: int, +) -> dict[str, Any]: + assert result["status"] == "succeeded" + assert result["execution_mode"] == "dry_run" + assert result["hardware_execution_allowed"] is False + assert result["orchestration_route"] == "mode_profile_create_preview" + assert result.get("mode_profile") is None + assert not any( + isinstance(issue, dict) and issue.get("severity") == "error" + for issue in result.get("validation", []) + ) + draft = result.get("mode_profile_draft") + assert isinstance(draft, dict) + assert draft["name"] == name + assert draft["operation_count"] == operation_count + assert len(_intent_actions(result)) == operation_count + return draft + + +def _assert_atomic_failure(result: dict[str, Any], user_root: Path) -> None: + assert result["status"] != "succeeded" + assert result["execution_mode"] == "dry_run" + assert result["hardware_execution_allowed"] is False + assert result.get("mode_profile_draft") is None + assert _intent_actions(result) == [] + assert result.get("function_config") is None + assert result.get("function_configs") == {} + assert result.get("command_plan") is None + assert result.get("command_plans") == {} + assert result.get("scene_config") is None + assert result.get("execution_report") is None + assert not list(user_root.rglob("*.yaml")) + + +TYPED_SCENE_CASES = ( + SceneCase( + "integer-temperature", + "整数恒温模式", + "climate.set_temperature", + "vehicle", + {"temperature_c": 25}, + ), + SceneCase( + "percentage-shade", + "柔光遮阳模式", + "roof.set_shade_position", + "vehicle", + {"position_pct": 37}, + ), + SceneCase( + "binary-state", + "迎宾氛围模式", + "ambient_light.set_power", + "vehicle", + {"state": "on"}, + ), + SceneCase( + "color-and-percentage", + "蓝光阅读模式", + "ambient_light.set_illumination", + "vehicle", + {"color_hex": "#2040FF", "brightness_pct": 60}, + ), + SceneCase( + "fan-level", + "清风通勤模式", + "climate.set_fan_level", + "vehicle", + {"level": 4}, + ), + SceneCase( + "two-axis-percentage", + "定向送风模式", + "outlet.set_position_percent", + "passenger_right", + {"horizontal_pct": 20, "vertical_pct": 80}, + ), + SceneCase( + "scoped-window", + "后排换气模式", + "window.set_position", + "rear_right", + {"position_pct": 45}, + ), + SceneCase( + "state-and-level", + "副驾暖座模式", + "seat.set_heating", + "passenger", + {"state": "on", "level": 3}, + ), + SceneCase( + "cycle-enum", + "外循环通风模式", + "climate.set_cycle_mode", + "vehicle", + {"mode": "external"}, + ), + SceneCase( + "control-enum", + "自动温控模式", + "climate.set_control_mode", + "vehicle", + {"mode": "auto"}, + ), + SceneCase( + "all-reading-lights", + "全舱暗光模式", + "interior_light.set_reading_power", + "all", + {"state": "off"}, + ), + SceneCase( + "seat-angle", + "副驾舒展模式", + "seat.set_backrest_recline", + "passenger", + {"angle_deg": 120}, + ), +) + + +@pytest.mark.parametrize("case", TYPED_SCENE_CASES, ids=lambda case: case.case_id) +def test_name_only_scene_parameter_types_are_registry_bounded_and_compile( + mode_service: tuple[SeatDemoService, Path], + case: SceneCase, +) -> None: + service, user_root = mode_service + catalog = AssistantCapabilityCatalog(service).snapshot() + registered = { + str(item["id"]): item + for item in catalog["capabilities"] + if isinstance(item, dict) + } + assert case.capability in registered + assert registered[case.capability]["assistant_callable"] is True + + result = _preview_without_hardware( + service, + f"构建一个{case.mode_name}", + conversation_id=f"conv-typed-scene-{case.case_id}", + assistant_plan=_scene_plan( + _scene_request(case.capability, case.target, case.parameters) + ), + ) + + draft = _assert_successful_preview(result, name=case.mode_name, operation_count=1) + assert draft["generation_source"] == "codex_scene_composition" + _assert_parameter_subset( + result, + case.capability, + case.target, + case.parameters, + ) + assert not list(user_root.rglob("*.yaml")) + + +BOUNDARY_SCENE_CASES = ( + SceneCase("temperature-min", "温度下界模式", "climate.set_temperature", "vehicle", {"temperature_c": 18}), + SceneCase("temperature-max", "温度上界模式", "climate.set_temperature", "vehicle", {"temperature_c": 32}), + SceneCase("fan-min", "微风下界模式", "climate.set_fan_level", "vehicle", {"level": 1}), + SceneCase("fan-max", "强风上界模式", "climate.set_fan_level", "vehicle", {"level": 7}), + SceneCase("shade-min", "遮阳下界模式", "roof.set_shade_position", "vehicle", {"position_pct": 0}), + SceneCase("shade-max", "遮阳上界模式", "roof.set_shade_position", "vehicle", {"position_pct": 100}), + SceneCase("window-min", "车窗下界模式", "window.set_position", "front_left", {"position_pct": 0}), + SceneCase("window-max", "车窗上界模式", "window.set_position", "front_left", {"position_pct": 100}), + SceneCase("brightness-min", "暗光下界模式", "ambient_light.set_brightness_level", "vehicle", {"brightness_level": 1}), + SceneCase("brightness-max", "亮光上界模式", "ambient_light.set_brightness_level", "vehicle", {"brightness_level": 5}), + SceneCase("color-index-min", "色号下界模式", "ambient_light.set_color_index", "vehicle", {"color_index": 1}), + SceneCase("color-index-max", "色号上界模式", "ambient_light.set_color_index", "vehicle", {"color_index": 127}), + SceneCase("outlet-min", "风口下界模式", "outlet.set_position_percent", "driver_left", {"horizontal_pct": 0, "vertical_pct": 0}), + SceneCase("outlet-max", "风口上界模式", "outlet.set_position_percent", "driver_left", {"horizontal_pct": 100, "vertical_pct": 100}), + SceneCase("heating-min", "暖座下界模式", "seat.set_heating", "passenger", {"state": "on", "level": 1}), + # The passenger CAN0 reusable chain is physically bounded to levels 1-3. + # Levels 4-5 remain declared by the broad domain catalog but currently do + # not compile into a reusable Denza N7 mode. + SceneCase("heating-max", "暖座上界模式", "seat.set_heating", "passenger", {"state": "on", "level": 3}), +) + + +@pytest.mark.parametrize("case", BOUNDARY_SCENE_CASES, ids=lambda case: case.case_id) +def test_scene_parameter_inclusive_boundaries_compile_without_hardware( + mode_service: tuple[SeatDemoService, Path], + case: SceneCase, +) -> None: + service, user_root = mode_service + result = _preview_without_hardware( + service, + f"构建一个{case.mode_name}", + conversation_id=f"conv-boundary-{case.case_id}", + assistant_plan=_scene_plan( + _scene_request(case.capability, case.target, case.parameters) + ), + ) + + _assert_successful_preview(result, name=case.mode_name, operation_count=1) + _assert_parameter_subset(result, case.capability, case.target, case.parameters) + assert not list(user_root.rglob("*.yaml")) + + +BASE_MODE_NAME = "参数基线模式" +BASE_REQUESTS = ( + _scene_request("climate.set_temperature", "vehicle", {"temperature_c": 23}), + _scene_request("climate.set_fan_level", "vehicle", {"level": 2}), + _scene_request("climate.set_cycle_mode", "vehicle", {"mode": "internal"}), + _scene_request("roof.set_shade_position", "vehicle", {"position_pct": 30}), + _scene_request( + "ambient_light.set_illumination", + "vehicle", + {"color_hex": "#0000FF", "brightness_pct": 50}, + ), + _scene_request("window.set_position", "front_right", {"position_pct": 25}), + _scene_request("seat.set_heating", "passenger", {"state": "on", "level": 2}), + _scene_request( + "outlet.set_position_percent", + "driver_left", + {"horizontal_pct": 25, "vertical_pct": 75}, + ), +) + + +def _build_parameter_base( + service: SeatDemoService, + *, + conversation_id: str, +) -> dict[str, Any]: + result = _preview_without_hardware( + service, + f"构建一个{BASE_MODE_NAME}", + conversation_id=conversation_id, + assistant_plan=_scene_plan(*BASE_REQUESTS), + ) + _assert_successful_preview( + result, + name=BASE_MODE_NAME, + operation_count=len(BASE_REQUESTS), + ) + return result + + +@pytest.mark.parametrize( + ("message", "expected_name"), + ( + ("麻烦帮我创建一个晨间一号模式", "晨间一号模式"), + ("给我新建一套午后二号模式", "午后二号模式"), + ("请帮忙新增夜间三号模式", "夜间三号模式"), + ("生成一个周末四号模式", "周末四号模式"), + ), +) +def test_name_only_creation_verbs_counters_and_politeness_share_one_route( + mode_service: tuple[SeatDemoService, Path], + message: str, + expected_name: str, +) -> None: + service, user_root = mode_service + result = _preview_without_hardware( + service, + message, + conversation_id="conv-create-language-" + str(abs(hash(message)))[:12], + assistant_plan=_scene_plan( + _scene_request("climate.set_temperature", "vehicle", {"temperature_c": 24}) + ), + ) + + draft = _assert_successful_preview(result, name=expected_name, operation_count=1) + assert draft["generation_source"] == "codex_scene_composition" + assert not list(user_root.rglob("*.yaml")) + + +DRAFT_CHANGE_CASES = ( + DraftChangeCase( + "integer-temperature", + "把参数基线模式的空调温度改为26度", + "climate.set_temperature", + "vehicle", + {"temperature_c": 26}, + ), + DraftChangeCase( + "percentage-shade", + "请帮我把参数基线模式里的遮阳帘开到65%", + "roof.set_shade_position", + "vehicle", + {"position_pct": 65}, + ), + DraftChangeCase( + "color-pair", + "将参数基线模式中的氛围灯改为红色且亮度40%", + "ambient_light.set_illumination", + "vehicle", + {"color_hex": "#FF0000", "brightness_pct": 40}, + ), + DraftChangeCase( + "fan-level", + "参数基线模式,空调风量调到5档", + "climate.set_fan_level", + "vehicle", + {"level": 5}, + ), + DraftChangeCase( + "scoped-window", + "把参数基线模式中的右前车窗开到60%", + "window.set_position", + "front_right", + {"position_pct": 60}, + ), + DraftChangeCase( + "seat-state-level", + "把参数基线模式里的副驾座椅加热调到3档", + "seat.set_heating", + "passenger", + {"state": "on", "level": 3}, + ), + DraftChangeCase( + "cycle-enum", + "修改参数基线模式,空调切换为外循环", + "climate.set_cycle_mode", + "vehicle", + {"mode": "external"}, + ), + DraftChangeCase( + "outlet-two-axis", + "把参数基线模式里的主驾左风口调到水平60%垂直40%", + "outlet.set_position_percent", + "driver_left", + {"horizontal_pct": 60, "vertical_pct": 40}, + ), +) + + +@pytest.mark.parametrize("case", DRAFT_CHANGE_CASES, ids=lambda case: case.case_id) +def test_pending_draft_can_replace_each_parameter_type_and_preserve_siblings( + mode_service: tuple[SeatDemoService, Path], + case: DraftChangeCase, +) -> None: + service, user_root = mode_service + conversation_id = f"conv-draft-change-{case.case_id}" + built = _build_parameter_base(service, conversation_id=conversation_id) + before = _action_map(built) + + revised = _preview_without_hardware( + service, + case.message, + conversation_id=conversation_id, + assistant_plan=_scene_plan( + _scene_request(case.capability, case.target, case.parameters) + ), + draft_context=_draft_reference(built), + ) + + source_draft = built["mode_profile_draft"] + draft = _assert_successful_preview( + revised, + name=BASE_MODE_NAME, + operation_count=len(BASE_REQUESTS), + ) + assert draft["generation_source"] == "draft_revision" + assert draft["draft_revision"] == source_draft["draft_revision"] + 1 + assert draft["revises_source_request_id"] == source_draft["source_request_id"] + assert draft["requested_operation_count"] == 1 + assert draft["changed_operation_count"] == 1 + _assert_parameter_subset( + revised, + case.capability, + case.target, + case.parameters, + ) + after = _action_map(revised) + changed_key = (case.capability, case.target) + assert set(after) == set(before) + assert { + key: parameters + for key, parameters in after.items() + if key != changed_key + } == { + key: parameters + for key, parameters in before.items() + if key != changed_key + } + assert not list(user_root.rglob("*.yaml")) + + +def test_pending_draft_can_append_one_new_capability_without_replacing_siblings( + mode_service: tuple[SeatDemoService, Path], +) -> None: + service, user_root = mode_service + conversation_id = "conv-draft-append-capability" + built = _build_parameter_base(service, conversation_id=conversation_id) + before = _action_map(built) + revised = _preview_without_hardware( + service, + "把参数基线模式里的氛围灯关闭", + conversation_id=conversation_id, + assistant_plan=_scene_plan( + _scene_request("ambient_light.set_power", "vehicle", {"state": "off"}) + ), + draft_context=_draft_reference(built), + ) + + draft = _assert_successful_preview( + revised, + name=BASE_MODE_NAME, + operation_count=len(BASE_REQUESTS) + 1, + ) + assert draft["requested_operation_count"] == 1 + assert draft["changed_operation_count"] == 1 + after = _action_map(revised) + assert after[("ambient_light.set_power", "vehicle")] == {"state": "off"} + assert {key: after[key] for key in before} == before + assert not list(user_root.rglob("*.yaml")) + + +def test_repeating_the_existing_value_is_a_safe_noop_revision( + mode_service: tuple[SeatDemoService, Path], +) -> None: + service, user_root = mode_service + conversation_id = "conv-draft-noop-revision" + built = _build_parameter_base(service, conversation_id=conversation_id) + revised = _preview_without_hardware( + service, + "把参数基线模式的空调温度调到23度", + conversation_id=conversation_id, + assistant_plan=_scene_plan( + _scene_request("climate.set_temperature", "vehicle", {"temperature_c": 23}) + ), + draft_context=_draft_reference(built), + ) + + draft = _assert_successful_preview( + revised, + name=BASE_MODE_NAME, + operation_count=len(BASE_REQUESTS), + ) + assert draft["draft_revision"] == 2 + assert draft["requested_operation_count"] == 1 + assert draft["changed_operation_count"] == 0 + assert _action_map(revised) == _action_map(built) + assert not list(user_root.rglob("*.yaml")) + + +@pytest.mark.parametrize( + "message", + ( + "把参数基线模式的空调温度调整到26度", + "请帮我 把 参数基线模式 中的 空调温度设置为26度", + "修改参数基线模式,空调温度调至26℃", + "参数基线模式,空调温度调成26度。", + "麻烦将参数基线模式里的空调温度变更为26度", + ), +) +def test_draft_update_word_order_synonyms_and_politeness_keep_one_meaning( + mode_service: tuple[SeatDemoService, Path], + message: str, +) -> None: + service, user_root = mode_service + conversation_id = "conv-language-" + str(abs(hash(message)))[:12] + built = _build_parameter_base(service, conversation_id=conversation_id) + revised = _preview_without_hardware( + service, + message, + conversation_id=conversation_id, + assistant_plan=_scene_plan( + _scene_request( + "climate.set_temperature", + "vehicle", + {"temperature_c": 26}, + ) + ), + draft_context=_draft_reference(built), + ) + + _assert_successful_preview( + revised, + name=BASE_MODE_NAME, + operation_count=len(BASE_REQUESTS), + ) + assert revised["mode_profile_draft"]["requested_operation_count"] == 1 + assert revised["mode_profile_draft"]["changed_operation_count"] == 1 + _assert_parameter_subset( + revised, + "climate.set_temperature", + "vehicle", + {"temperature_c": 26}, + ) + assert not list(user_root.rglob("*.yaml")) + + +def test_multi_action_revision_is_atomic_and_preserves_every_unmentioned_sibling( + mode_service: tuple[SeatDemoService, Path], +) -> None: + service, user_root = mode_service + conversation_id = "conv-multi-action-revision" + built = _build_parameter_base(service, conversation_id=conversation_id) + before = _action_map(built) + replacements = ( + _scene_request("climate.set_temperature", "vehicle", {"temperature_c": 27}), + _scene_request("climate.set_fan_level", "vehicle", {"level": 6}), + _scene_request("roof.set_shade_position", "vehicle", {"position_pct": 10}), + ) + + revised = _preview_without_hardware( + service, + "把参数基线模式的空调温度调到27度、风量调到6档,遮阳帘开到10%", + conversation_id=conversation_id, + assistant_plan=_scene_plan(*replacements), + draft_context=_draft_reference(built), + ) + + _assert_successful_preview( + revised, + name=BASE_MODE_NAME, + operation_count=len(BASE_REQUESTS), + ) + assert revised["mode_profile_draft"]["requested_operation_count"] == 3 + assert revised["mode_profile_draft"]["changed_operation_count"] == 3 + expected_changes = { + ("climate.set_temperature", "vehicle"): {"temperature_c": 27}, + ("climate.set_fan_level", "vehicle"): {"level": 6}, + ("roof.set_shade_position", "vehicle"): {"position_pct": 10}, + } + after = _action_map(revised) + for key, parameters in expected_changes.items(): + for name, value in parameters.items(): + assert after[key][name] == value + assert { + key: parameters for key, parameters in after.items() if key not in expected_changes + } == { + key: parameters for key, parameters in before.items() if key not in expected_changes + } + assert not list(user_root.rglob("*.yaml")) + + +def test_continuous_revisions_form_a_lineage_and_keep_prior_changes( + mode_service: tuple[SeatDemoService, Path], +) -> None: + service, user_root = mode_service + conversation_id = "conv-continuous-draft-lineage" + first = _build_parameter_base(service, conversation_id=conversation_id) + second = _preview_without_hardware( + service, + "把参数基线模式的空调温度调到26度", + conversation_id=conversation_id, + assistant_plan=_scene_plan( + _scene_request("climate.set_temperature", "vehicle", {"temperature_c": 26}) + ), + draft_context=_draft_reference(first), + ) + third = _preview_without_hardware( + service, + "把参数基线模式的遮阳帘开到70%", + conversation_id=conversation_id, + assistant_plan=_scene_plan( + _scene_request("roof.set_shade_position", "vehicle", {"position_pct": 70}) + ), + draft_context=_draft_reference(second), + ) + + first_draft = first["mode_profile_draft"] + second_draft = second["mode_profile_draft"] + third_draft = _assert_successful_preview( + third, + name=BASE_MODE_NAME, + operation_count=len(BASE_REQUESTS), + ) + assert [ + first_draft["draft_revision"], + second_draft["draft_revision"], + third_draft["draft_revision"], + ] == [1, 2, 3] + assert second_draft["revises_source_request_id"] == first_draft["source_request_id"] + assert third_draft["revises_source_request_id"] == second_draft["source_request_id"] + _assert_parameter_subset( + third, + "climate.set_temperature", + "vehicle", + {"temperature_c": 26}, + ) + _assert_parameter_subset( + third, + "roof.set_shade_position", + "vehicle", + {"position_pct": 70}, + ) + assert not list(user_root.rglob("*.yaml")) + + +def test_unscoped_revision_rejects_a_multi_target_capability_atomically( + mode_service: tuple[SeatDemoService, Path], +) -> None: + service, user_root = mode_service + conversation_id = "conv-multizone-ambiguous-revision" + built = _preview_without_hardware( + service, + "构建一个双区矩阵模式", + conversation_id=conversation_id, + assistant_plan=_scene_plan( + _scene_request("climate.set_temperature", "driver", {"temperature_c": 23}), + _scene_request("climate.set_temperature", "passenger", {"temperature_c": 24}), + _scene_request("climate.set_fan_level", "vehicle", {"level": 2}), + ), + ) + _assert_successful_preview(built, name="双区矩阵模式", operation_count=3) + + rejected = _preview_without_hardware( + service, + "把双区矩阵模式的空调温度调到25度", + conversation_id=conversation_id, + assistant_plan=_scene_plan( + _scene_request("climate.set_temperature", "vehicle", {"temperature_c": 25}) + ), + draft_context=_draft_reference(built), + ) + + _assert_atomic_failure(rejected, user_root) + assert "多个目标" in rejected["mode_creation_failure"]["reason"] + + +def test_explicit_target_revision_changes_only_that_zone( + mode_service: tuple[SeatDemoService, Path], +) -> None: + service, user_root = mode_service + conversation_id = "conv-multizone-explicit-revision" + built = _preview_without_hardware( + service, + "构建一个双区独立模式", + conversation_id=conversation_id, + assistant_plan=_scene_plan( + _scene_request("climate.set_temperature", "driver", {"temperature_c": 23}), + _scene_request("climate.set_temperature", "passenger", {"temperature_c": 24}), + ), + ) + + revised = _preview_without_hardware( + service, + "把双区独立模式的副驾空调温度调到26度", + conversation_id=conversation_id, + assistant_plan=_scene_plan( + _scene_request("climate.set_temperature", "passenger", {"temperature_c": 26}) + ), + draft_context=_draft_reference(built), + ) + + _assert_successful_preview(revised, name="双区独立模式", operation_count=2) + _assert_parameter_subset( + revised, + "climate.set_temperature", + "driver", + {"temperature_c": 23}, + ) + _assert_parameter_subset( + revised, + "climate.set_temperature", + "passenger", + {"temperature_c": 26}, + ) + assert not list(user_root.rglob("*.yaml")) + + +@pytest.mark.parametrize( + "message", + ( + "把参数基线模式的空调温度调到26度合适吗?", + "参数基线模式里的遮阳帘为什么要关到30%?", + "调整参数基线模式的氛围灯设置有哪些?", + "把参数基线模式的副驾座椅加热调到4档会怎样?", + "参数基线模式的右前车窗开到60%是否安全?", + ), +) +def test_question_shaped_mode_turns_never_modify_a_draft_or_touch_hardware( + mode_service: tuple[SeatDemoService, Path], + message: str, +) -> None: + service, user_root = mode_service + conversation_id = "conv-question-" + str(abs(hash(message)))[:12] + built = _build_parameter_base(service, conversation_id=conversation_id) + reference = _draft_reference(built) + + authorization = _authorize_vehicle_request( + message, + mode_profile_draft_context=reference, + ) + assert authorization.instruction is None + assert authorization.reason == "mode_profile_query" + result = _preview_without_hardware( + service, + message, + conversation_id=conversation_id, + draft_context=reference, + ) + + assert result["status"] == "needs_confirmation" + assert result["orchestration_route"] == "mode_profile_query" + assert result["execution_mode"] == "dry_run" + assert result["hardware_execution_allowed"] is False + assert result.get("mode_profile_draft") is None + assert _intent_actions(result) == [] + assert result["function_configs"] == {} + assert result["command_plans"] == {} + assert not list(user_root.rglob("*.yaml")) + + +INVALID_PARAMETER_CASES = ( + SceneCase("temperature-low", "非法低温模式", "climate.set_temperature", "vehicle", {"temperature_c": 17}), + SceneCase("temperature-high", "非法高温模式", "climate.set_temperature", "vehicle", {"temperature_c": 33}), + SceneCase("fan-low", "非法低风模式", "climate.set_fan_level", "vehicle", {"level": 0}), + SceneCase("fan-high", "非法高风模式", "climate.set_fan_level", "vehicle", {"level": 8}), + SceneCase("shade-low", "非法遮阳下界模式", "roof.set_shade_position", "vehicle", {"position_pct": -1}), + SceneCase("shade-high", "非法遮阳上界模式", "roof.set_shade_position", "vehicle", {"position_pct": 101}), + SceneCase("window-target", "非法全窗比例模式", "window.set_position", "all", {"position_pct": 50}), + SceneCase("window-high", "非法车窗比例模式", "window.set_position", "front_left", {"position_pct": 101}), + SceneCase("color-format", "非法颜色模式", "ambient_light.set_illumination", "vehicle", {"color_hex": "red", "brightness_pct": 50}), + SceneCase("color-missing-brightness", "缺少亮度模式", "ambient_light.set_illumination", "vehicle", {"color_hex": "#FF0000"}), + SceneCase("brightness-high", "非法亮度模式", "ambient_light.set_brightness_level", "vehicle", {"brightness_level": 6}), + SceneCase("outlet-missing-axis", "缺少风口轴模式", "outlet.set_position_percent", "driver_left", {"horizontal_pct": 50}), + SceneCase("outlet-high", "非法风口比例模式", "outlet.set_position_percent", "driver_left", {"horizontal_pct": 101, "vertical_pct": 50}), + SceneCase("heating-native-gap-4", "暖座四档模式", "seat.set_heating", "passenger", {"state": "on", "level": 4}), + SceneCase("heating-native-gap-5", "暖座五档模式", "seat.set_heating", "passenger", {"state": "on", "level": 5}), + SceneCase("heating-level", "非法暖座档位模式", "seat.set_heating", "passenger", {"state": "on", "level": 6}), + SceneCase("heating-target", "非法暖座目标模式", "seat.set_heating", "driver", {"state": "on", "level": 2}), + SceneCase("cycle-enum", "非法循环模式", "climate.set_cycle_mode", "vehicle", {"mode": "mixed"}), +) + + +@pytest.mark.parametrize("case", INVALID_PARAMETER_CASES, ids=lambda case: case.case_id) +def test_one_invalid_parameter_discards_valid_siblings_and_never_returns_a_card( + mode_service: tuple[SeatDemoService, Path], + case: SceneCase, +) -> None: + service, user_root = mode_service + result = _preview_without_hardware( + service, + f"构建一个{case.mode_name}", + conversation_id=f"conv-invalid-{case.case_id}", + assistant_plan=_scene_plan( + _scene_request( + "ambient_light.set_power", + "vehicle", + {"state": "off"}, + ), + _scene_request(case.capability, case.target, case.parameters), + ), + ) + + _assert_atomic_failure(result, user_root) + + +INVALID_DRAFT_CHANGES = ( + ( + "temperature-low", + "把参数基线模式的空调温度调到17度", + _scene_plan( + _scene_request("climate.set_temperature", "vehicle", {"temperature_c": 17}) + ), + ), + ( + "temperature-high", + "把参数基线模式的空调温度调到33度", + _scene_plan( + _scene_request("climate.set_temperature", "vehicle", {"temperature_c": 33}) + ), + ), + ( + "fan-high", + "把参数基线模式的空调风量调到8档", + _scene_plan( + _scene_request("climate.set_fan_level", "vehicle", {"level": 8}) + ), + ), + ( + "shade-high", + "把参数基线模式的遮阳帘开到101%", + _scene_plan( + _scene_request("roof.set_shade_position", "vehicle", {"position_pct": 101}) + ), + ), + ( + "window-high", + "把参数基线模式的右前车窗开到101%", + _scene_plan( + _scene_request("window.set_position", "front_right", {"position_pct": 101}) + ), + ), + ( + "heating-native-gap", + "把参数基线模式的副驾座椅加热调到4档", + _scene_plan( + _scene_request( + "seat.set_heating", + "passenger", + {"state": "on", "level": 4}, + ) + ), + ), + ( + "outlet-missing-axis", + "把参数基线模式的主驾左风口水平调到60%", + _scene_plan( + _scene_request( + "outlet.set_position_percent", + "driver_left", + {"horizontal_pct": 60}, + ) + ), + ), + ( + "multiple-values", + "把参数基线模式的空调温度调到24度和26度", + None, + ), + ( + "valid-plus-unsupported", + "把参数基线模式的空调温度调到26度,同时启动曲速引擎", + None, + ), +) + + +@pytest.mark.parametrize( + ("case_id", "message", "assistant_plan"), + INVALID_DRAFT_CHANGES, + ids=[item[0] for item in INVALID_DRAFT_CHANGES], +) +def test_invalid_draft_change_is_atomic_and_leaves_the_source_draft_usable( + mode_service: tuple[SeatDemoService, Path], + case_id: str, + message: str, + assistant_plan: dict[str, object] | None, +) -> None: + service, user_root = mode_service + conversation_id = f"conv-invalid-draft-{case_id}" + built = _build_parameter_base(service, conversation_id=conversation_id) + before = _action_map(built) + source_draft = built["mode_profile_draft"] + + rejected = _preview_without_hardware( + service, + message, + conversation_id=conversation_id, + assistant_plan=assistant_plan, + draft_context=_draft_reference(built), + ) + + _assert_atomic_failure(rejected, user_root) + assert any( + isinstance(issue, dict) + and issue.get("code") == "MODE_PROFILE_DRAFT_UPDATE_REJECTED" + for issue in rejected.get("validation", []) + ) + with patch.object( + service.hardware_execution, + "reserve", + side_effect=AssertionError("source draft trial reached a hardware lease"), + ): + trial = service.trial_mode_profile_draft( + str(source_draft["name"]), + str(source_draft["instruction"]), + str(source_draft["source_request_id"]), + str(source_draft["vehicle_id"]), + ) + assert trial["status"] == "succeeded" + assert trial["execution_mode"] == "dry_run" + assert trial["hardware_execution_allowed"] is False + assert _action_map(trial) == before + assert not list(user_root.rglob("*.yaml")) + + +FORBIDDEN_REUSABLE_ACTIONS = ( + ( + "rear-door-lock", + _scene_request("door.set_rear_lock", "rear", {"state": "lock"}), + ), + ( + "compressor-shortcut", + _scene_request("climate.set_ac", "vehicle", {"state": "on"}), + ), + ( + "driver-timed-axis", + _scene_request( + "seat.set_driver_axis_native", + "driver", + {"axis": "horizontal", "direction": "forward"}, + duration_ms=500, + ), + ), + ( + "dynamic-seat", + _scene_request("seat.start_dynamic_mode", "passenger", {"mode_id": 1}), + ), + ( + "save-preset-side-effect", + _scene_request( + "seat.save_posture_preset", + "passenger", + {"preset_name": "不应保存"}, + operation="save", + ), + ), + ( + "apply-preset-indirection", + _scene_request( + "seat.apply_posture_preset", + "passenger", + {"preset_name": "不存在"}, + operation="apply", + ), + ), + ( + "calibrated-outlet", + _scene_request( + "outlet.set_calibrated_position", + "driver_left", + {"horizontal_value": -20, "vertical_value": 30}, + ), + ), + ( + "transient-window-motion", + _scene_request("window.set_motion", "front_left", {"motion": "stop"}), + ), + ( + "transient-shade-motion", + _scene_request("roof.set_shade_motion", "vehicle", {"motion": "stop"}), + ), +) + + +@pytest.mark.parametrize( + ("case_id", "forbidden_request"), + FORBIDDEN_REUSABLE_ACTIONS, + ids=[item[0] for item in FORBIDDEN_REUSABLE_ACTIONS], +) +def test_registered_but_non_reusable_or_transient_actions_are_rejected_atomically( + mode_service: tuple[SeatDemoService, Path], + case_id: str, + forbidden_request: dict[str, object], +) -> None: + service, user_root = mode_service + catalog_ids = { + item["id"] for item in AssistantCapabilityCatalog(service).snapshot()["capabilities"] + } + assert forbidden_request["capability"] in catalog_ids + result = _preview_without_hardware( + service, + f"构建一个禁止动作{case_id}模式", + conversation_id=f"conv-forbidden-{case_id}", + assistant_plan=_scene_plan( + _scene_request("climate.set_temperature", "vehicle", {"temperature_c": 24}), + forbidden_request, + ), + ) + + _assert_atomic_failure(result, user_root) + + +def test_duplicate_scene_key_is_rejected_instead_of_last_write_wins( + mode_service: tuple[SeatDemoService, Path], +) -> None: + service, user_root = mode_service + result = _preview_without_hardware( + service, + "构建一个冲突温度模式", + conversation_id="conv-duplicate-scene-key", + assistant_plan=_scene_plan( + _scene_request("climate.set_temperature", "vehicle", {"temperature_c": 24}), + _scene_request("climate.set_temperature", "vehicle", {"temperature_c": 26}), + ), + ) + + _assert_atomic_failure(result, user_root) + + +MAXIMUM_SCENE_REQUESTS = ( + _scene_request("climate.set_power", "vehicle", {"state": "on"}), + _scene_request("climate.set_temperature", "vehicle", {"temperature_c": 24}), + _scene_request("climate.set_fan_level", "vehicle", {"level": 2}), + _scene_request("climate.set_cycle_mode", "vehicle", {"mode": "internal"}), + _scene_request("climate.set_control_mode", "vehicle", {"mode": "auto"}), + _scene_request("climate.set_front_defrost", "vehicle", {"state": "off"}), + _scene_request("roof.set_shade_position", "vehicle", {"position_pct": 0}), + _scene_request("window.set_position", "front_left", {"position_pct": 0}), + _scene_request("window.set_position", "front_right", {"position_pct": 0}), + _scene_request("ambient_light.set_power", "vehicle", {"state": "off"}), + _scene_request("interior_light.set_indoor_power", "vehicle", {"state": "off"}), + _scene_request("interior_light.set_reading_power", "all", {"state": "off"}), +) + + +def test_scene_request_limit_accepts_exactly_twelve_verified_actions( + mode_service: tuple[SeatDemoService, Path], +) -> None: + service, user_root = mode_service + assert len(MAXIMUM_SCENE_REQUESTS) == MAX_PLAN_REQUESTS == 12 + result = _preview_without_hardware( + service, + "构建一个十二动作模式", + conversation_id="conv-twelve-scene-actions", + assistant_plan=_scene_plan(*MAXIMUM_SCENE_REQUESTS), + ) + + _assert_successful_preview(result, name="十二动作模式", operation_count=12) + assert not list(user_root.rglob("*.yaml")) + + +def test_scene_request_limit_rejects_thirteen_before_any_hardware_or_persistence( + mode_service: tuple[SeatDemoService, Path], +) -> None: + service, user_root = mode_service + oversized = ( + *MAXIMUM_SCENE_REQUESTS, + _scene_request("outlet.set_switch_state", "driver_left", {"state": "close"}), + ) + assert len(oversized) == MAX_PLAN_REQUESTS + 1 + + with pytest.raises(ValueError, match="too many requests"): + _preview_without_hardware( + service, + "构建一个十三动作模式", + conversation_id="conv-thirteen-scene-actions", + assistant_plan=_scene_plan(*oversized), + ) + assert not list(user_root.rglob("*.yaml")) + + +def test_known_mode_name_reuses_repository_profile_even_if_a_plan_is_supplied( + mode_service: tuple[SeatDemoService, Path], +) -> None: + service, user_root = mode_service + nap = service.mode_profiles.get_profile("nap", "denza_n7") + result = _preview_without_hardware( + service, + "构建一个小憩模式", + conversation_id="conv-known-mode-exact-reuse-matrix", + assistant_plan=_scene_plan( + _scene_request("climate.set_temperature", "vehicle", {"temperature_c": 31}) + ), + ) + + draft = _assert_successful_preview( + result, + name=nap.name, + operation_count=len(nap.operations), + ) + assert draft["generation_source"] == "registered_mode_profile" + assert draft["existing_mode_id"] == "nap" + expected = { + (operation.capability, operation.target_scope): operation.parameters + for operation in nap.operations + } + actual = _action_map(result) + for (capability, target), parameters in expected.items(): + rendered_target = "passenger" if target == "front_right" and capability.startswith("seat.") else target + for name, value in parameters.items(): + assert actual[(capability, rendered_target)][name] == value + assert not list(user_root.rglob("*.yaml")) diff --git a/tests/integration/test_named_mode_inference_regression.py b/tests/integration/test_named_mode_inference_regression.py index c02cc32..ba5b1c5 100644 --- a/tests/integration/test_named_mode_inference_regression.py +++ b/tests/integration/test_named_mode_inference_regression.py @@ -95,6 +95,51 @@ LUNCH_BREAK_SCENE_PLAN = { "confidence": 0.92, } +VIEWING_SCENE_PLAN = { + "detected_domains": ["ambient_light", "roof", "climate", "interior_light"], + "requests": [ + _scene_request( + "ambient_light", + "ambient_light.set_mode", + "vehicle", + {"mode": "cinema"}, + ), + _scene_request( + "roof", + "roof.set_shade_position", + "vehicle", + {"position_pct": 0}, + ), + _scene_request( + "climate", + "climate.set_temperature", + "vehicle", + {"temperature_c": 24}, + ), + _scene_request( + "climate", + "climate.set_fan_level", + "vehicle", + {"level": 2}, + ), + _scene_request( + "climate", + "climate.set_cycle_mode", + "vehicle", + {"mode": "internal"}, + ), + _scene_request( + "interior_light", + "interior_light.set_indoor_power", + "vehicle", + {"state": "off"}, + ), + ], + "ambiguities": [], + "decision_summary": ["组合遮光、低照明和舒适温度的观影场景。"], + "confidence": 0.94, +} + @pytest.fixture def vehicle_service(tmp_path: Path) -> SeatDemoService: @@ -124,6 +169,20 @@ def _build_nap_mode(service: SeatDemoService, conversation_id: str) -> dict[str, ) +def _build_viewing_mode( + service: SeatDemoService, + conversation_id: str, +) -> dict[str, object]: + return service.preview_native_can( + "构建一个观影模式", + provider="local", + conversation_id=conversation_id, + execution_mode="hardware", + vehicle_id="denza_n7", + assistant_plan=VIEWING_SCENE_PLAN, + ) + + def _intent_actions(result: dict[str, object]) -> list[dict[str, object]]: intent_ir = result.get("intent_ir") assert isinstance(intent_ir, dict) @@ -455,6 +514,250 @@ def test_pending_draft_can_be_revised_repeatedly_from_latest_server_artifact( ) == 26 +def test_viewing_draft_shade_close_then_100_percent_is_an_atomic_dry_run_chain( + vehicle_service: SeatDemoService, +) -> None: + conversation_id = "conv-viewing-shade-revision-chain" + built = _build_viewing_mode(vehicle_service, conversation_id) + assert built["status"] == "succeeded" + assert built["mode_profile_draft"]["operation_count"] == 6 + + def semantic_actions(result: dict[str, object]) -> dict[tuple[str, str, str], object]: + return { + ( + str(action["domain"]), + str(action["capability"]), + str( + action["target"].get("instance") + or action["target"].get("zone") + ), + ): action["parameters"] + for action in _intent_actions(result) + } + + with patch.object( + vehicle_service.hardware_execution, + "reserve", + side_effect=AssertionError("viewing draft revision reached hardware lease"), + ): + closed = vehicle_service.preview_native_can( + "把观影模式的遮阳帘变成关闭状态。", + provider="local", + conversation_id=conversation_id, + execution_mode="hardware", + vehicle_id="denza_n7", + mode_profile_draft_context=_draft_reference(built), + ) + opened = vehicle_service.preview_native_can( + "把观影模式的遮阳帘变成100%", + provider="local", + conversation_id=conversation_id, + execution_mode="hardware", + vehicle_id="denza_n7", + mode_profile_draft_context=_draft_reference(closed), + ) + + built_actions = semantic_actions(built) + closed_actions = semantic_actions(closed) + opened_actions = semantic_actions(opened) + shade_key = ("roof", "roof.set_shade_position", "vehicle") + + assert closed["status"] == opened["status"] == "succeeded" + assert closed["execution_mode"] == opened["execution_mode"] == "dry_run" + assert closed["hardware_execution_allowed"] is False + assert opened["hardware_execution_allowed"] is False + assert closed["orchestration_route"] == "mode_profile_create_preview" + assert opened["orchestration_route"] == "mode_profile_create_preview" + assert len(built_actions) == len(closed_actions) == len(opened_actions) == 6 + assert closed_actions[shade_key] == {"position_pct": 0} + assert opened_actions[shade_key] == {"position_pct": 100} + assert {key: value for key, value in closed_actions.items() if key != shade_key} == { + key: value for key, value in built_actions.items() if key != shade_key + } + assert {key: value for key, value in opened_actions.items() if key != shade_key} == { + key: value for key, value in built_actions.items() if key != shade_key + } + + closed_draft = closed["mode_profile_draft"] + opened_draft = opened["mode_profile_draft"] + assert closed_draft["draft_revision"] == 2 + assert closed_draft["requested_operation_count"] == 1 + assert closed_draft["changed_operation_count"] == 0 + assert opened_draft["draft_revision"] == 3 + assert opened_draft["revises_source_request_id"] == closed_draft[ + "source_request_id" + ] + assert opened_draft["requested_operation_count"] == 1 + assert opened_draft["changed_operation_count"] == 1 + + +@pytest.mark.parametrize( + ("change", "expected_position"), + ( + ("遮阳帘调整为1%", 1), + ("遮阳帘调为20%", 20), + ("遮阳帘改为30%", 30), + ("遮阳帘改成40%", 40), + ("遮阳帘变为50%", 50), + ("遮阳帘变成99%", 99), + ("遮阳帘换成100%", 100), + ("遮阳帘设置为0%", 0), + ("遮阳帘变成打开状态", 100), + ("遮阳帘变成半开状态", 50), + ), +) +def test_viewing_draft_accepts_supported_shade_absolute_expressions( + vehicle_service: SeatDemoService, + change: str, + expected_position: int, +) -> None: + conversation_id = f"conv-viewing-shade-alias-{expected_position}-{len(change)}" + built = _build_viewing_mode(vehicle_service, conversation_id) + + with patch.object( + vehicle_service.hardware_execution, + "reserve", + side_effect=AssertionError("shade draft alias reached hardware lease"), + ): + revised = vehicle_service.preview_native_can( + f"把观影模式的{change}", + provider="local", + conversation_id=conversation_id, + execution_mode="hardware", + vehicle_id="denza_n7", + mode_profile_draft_context=_draft_reference(built), + ) + + actions = _intent_actions(revised) + assert revised["status"] == "succeeded" + assert revised["execution_mode"] == "dry_run" + assert revised["hardware_execution_allowed"] is False + assert revised["mode_profile_draft"]["operation_count"] == 6 + assert next( + action["parameters"]["position_pct"] + for action in actions + if action["capability"] == "roof.set_shade_position" + ) == expected_position + + +def test_viewing_draft_updates_percent_integer_and_state_in_one_atomic_turn( + vehicle_service: SeatDemoService, +) -> None: + conversation_id = "conv-viewing-multi-parameter-revision" + built = _build_viewing_mode(vehicle_service, conversation_id) + + with patch.object( + vehicle_service.hardware_execution, + "reserve", + side_effect=AssertionError("multi-parameter draft update reached hardware"), + ): + revised = vehicle_service.preview_native_can( + "把观影模式的遮阳帘变成100%,空调温度变成25度,室内灯变成打开状态", + provider="local", + conversation_id=conversation_id, + execution_mode="hardware", + vehicle_id="denza_n7", + mode_profile_draft_context=_draft_reference(built), + ) + + by_capability = { + str(action["capability"]): action["parameters"] + for action in _intent_actions(revised) + } + assert revised["status"] == "succeeded" + assert revised["execution_mode"] == "dry_run" + assert revised["hardware_execution_allowed"] is False + assert revised["mode_profile_draft"]["operation_count"] == 6 + assert revised["mode_profile_draft"]["requested_operation_count"] == 3 + assert revised["mode_profile_draft"]["changed_operation_count"] == 3 + assert by_capability["roof.set_shade_position"] == {"position_pct": 100} + assert by_capability["climate.set_temperature"] == {"temperature_c": 25} + assert by_capability["interior_light.set_indoor_power"] == {"state": "on"} + assert by_capability["ambient_light.set_mode"] == {"mode": "cinema"} + assert by_capability["climate.set_fan_level"] == {"level": 2} + assert by_capability["climate.set_cycle_mode"] == {"mode": "internal"} + + +@pytest.mark.parametrize("position", (-1, 101)) +def test_viewing_draft_rejects_out_of_range_shade_without_partial_evidence( + vehicle_service: SeatDemoService, + position: int, +) -> None: + conversation_id = f"conv-viewing-invalid-shade-{abs(position)}" + built = _build_viewing_mode(vehicle_service, conversation_id) + + with patch.object( + vehicle_service.hardware_execution, + "reserve", + side_effect=AssertionError("invalid shade draft update reached hardware"), + ): + rejected = vehicle_service.preview_native_can( + f"把观影模式的遮阳帘变成{position}%", + provider="local", + conversation_id=conversation_id, + execution_mode="hardware", + vehicle_id="denza_n7", + mode_profile_draft_context=_draft_reference(built), + ) + + assert rejected["status"] == "needs_confirmation" + assert rejected["execution_mode"] == "dry_run" + assert rejected["hardware_execution_allowed"] is False + assert rejected["orchestration_route"] == "mode_profile_create_preview" + assert rejected.get("mode_profile_draft") is None + assert rejected["function_configs"] == {} + assert rejected["command_plans"] == {} + assert rejected["mode_creation_failure"]["code"] == ( + "MODE_PROFILE_DRAFT_UPDATE_REJECTED" + ) + + +@pytest.mark.parametrize( + "message", + ( + "把观影模式的遮阳帘变成100%吗", + "把观影模式的遮阳帘调整为50%好吗?", + "把观影模式的遮阳帘变成关闭状态吗。", + "把观影模式的遮阳帘变成100%合适么", + "观影模式里的遮阳帘应该变成多少呢", + ), +) +def test_viewing_draft_shade_question_never_updates_or_reaches_hardware( + vehicle_service: SeatDemoService, + message: str, +) -> None: + conversation_id = "conv-viewing-shade-question" + built = _build_viewing_mode(vehicle_service, conversation_id) + reference = _draft_reference(built) + + assert ( + parse_mode_profile_update(message, expected_mode_name=reference["name"]) + is None + ) + assert is_mode_profile_query(message, expected_mode_name=reference["name"]) + with patch.object( + vehicle_service.hardware_execution, + "reserve", + side_effect=AssertionError("shade question reached hardware lease"), + ): + result = vehicle_service.preview_native_can( + message, + provider="local", + conversation_id=conversation_id, + execution_mode="hardware", + vehicle_id="denza_n7", + mode_profile_draft_context=reference, + ) + + assert result["status"] == "needs_confirmation" + assert result["execution_mode"] == "dry_run" + assert result["hardware_execution_allowed"] is False + assert result["orchestration_route"] == "mode_profile_query" + assert result.get("mode_profile_draft") is None + assert result["function_configs"] == {} + assert result["command_plans"] == {} + + def test_pending_draft_reference_is_bound_to_conversation_name_and_vehicle( vehicle_service: SeatDemoService, ) -> None: @@ -871,6 +1174,68 @@ def test_codex_composes_an_unregistered_name_only_scene_from_runtime_capabilitie ) +def test_name_only_scene_label_does_not_leak_device_words_into_local_parser( + vehicle_service: SeatDemoService, +) -> None: + external_cycle_plan = { + "detected_domains": ["climate"], + "requests": [ + _scene_request( + "climate", + "climate.set_cycle_mode", + "vehicle", + {"mode": "external"}, + ) + ], + "ambiguities": [], + "decision_summary": ["使用外循环通风。"], + "confidence": 0.96, + } + + with patch.object( + vehicle_service.hardware_execution, + "reserve", + side_effect=AssertionError("name-only scene reached hardware lease"), + ): + result = vehicle_service.preview_native_can( + "构建一个外循环通风模式", + provider="local", + conversation_id="conv-name-only-label-is-not-an-action", + execution_mode="hardware", + vehicle_id="denza_n7", + assistant_plan=external_cycle_plan, + ) + + actions = _intent_actions(result) + assert result["status"] == "succeeded" + assert result["execution_mode"] == "dry_run" + assert result["hardware_execution_allowed"] is False + assert result["provider_used"] == "local_rules+codex_app_server" + assert result["orchestration_route"] == "mode_profile_create_preview" + assert result["mode_profile_draft"]["name"] == "外循环通风模式" + assert result["mode_profile_draft"]["operation_count"] == 1 + assert result["mode_profile_draft"]["generation_source"] == ( + "codex_scene_composition" + ) + assert not result["intent_ir"]["spec"]["ambiguities"] + assert [ + ( + action["domain"], + action["capability"], + action["target"], + action["parameters"], + ) + for action in actions + ] == [ + ( + "climate", + "climate.set_cycle_mode", + {"device": "climate", "zone": "vehicle"}, + {"mode": "external"}, + ) + ] + + def test_codex_scene_composition_still_rejects_capabilities_outside_the_registry( vehicle_service: SeatDemoService, ) -> None: diff --git a/tests/unit/assistant_stream_runtime.test.cjs b/tests/unit/assistant_stream_runtime.test.cjs index a4e88fc..e606711 100644 --- a/tests/unit/assistant_stream_runtime.test.cjs +++ b/tests/unit/assistant_stream_runtime.test.cjs @@ -323,6 +323,86 @@ test("a follow-up turn carries only the active authoritative draft reference", a assert.equal(Object.hasOwn(request.mode_profile_draft_context, "actions"), false); }); +test("the change-into predicate carries only a same-name opaque draft reference", async () => { + const events = modeDraftEvents("req-draft-change-into", 24); + const harness = createHarness(events); + harness.input.value = "构建一个午休模式"; + await harness.sandbox.CognitiveAgentAssistant.submit(); + + events.splice(0, events.length, + { type: "message.accepted", route: "agent", engine: "codex-app-server" }, + { type: "answer.completed", route: "agent", answer: "已处理" }, + ); + harness.input.value = "把午休模式的空调温度变成25度"; + await harness.sandbox.CognitiveAgentAssistant.submit(); + harness.input.value = "午休模式的空调温度变成25度了吗?"; + await harness.sandbox.CognitiveAgentAssistant.submit(); + harness.input.value = "把露营模式的空调温度变成25度"; + await harness.sandbox.CognitiveAgentAssistant.submit(); + + const sameMode = JSON.parse(harness.requestCalls[1].options.body); + const question = JSON.parse(harness.requestCalls[2].options.body); + const anotherMode = JSON.parse(harness.requestCalls[3].options.body); + assert.deepEqual( + Object.keys(sameMode.mode_profile_draft_context).sort(), + ["name", "source_request_id", "vehicle_id"], + ); + assert.equal(sameMode.mode_profile_draft_context.name, "午休模式"); + assert.equal(sameMode.mode_profile_draft_context.source_request_id, "req-draft-change-into"); + assert.equal(sameMode.mode_profile_draft_context.vehicle_id, "demo-vehicle"); + assert.equal(Object.hasOwn(question, "mode_profile_draft_context"), false); + assert.equal(Object.hasOwn(anotherMode, "mode_profile_draft_context"), false); +}); + +test("shade position rows explain the target opening at semantic boundaries", async () => { + const positions = [0, 30, 50, 100]; + const events = [ + { type: "message.accepted", route: "vehicle_control", engine: "n7web-mode-builder" }, + { + type: "action.completed", + success: true, + payload: { + status: "succeeded", + execution_mode: "dry_run", + orchestration_route: "mode_profile_create_preview", + mode_profile_draft: { + name: "遮阳测试模式", + instruction: "遮阳帘目标开度测试", + operation_count: positions.length, + source_request_id: "req-draft-shade-positions", + vehicle_id: "demo-vehicle", + }, + actions: positions.map((position) => ({ + domain: "roof", + capability: "roof.set_shade_position", + target: { zone: "vehicle" }, + parameters: { position_pct: position }, + })), + }, + }, + { type: "answer.completed", route: "vehicle_control", answer: "模式草稿已生成" }, + ]; + const harness = createHarness(events); + harness.input.value = "构建一个遮阳测试模式"; + + await harness.sandbox.CognitiveAgentAssistant.submit(); + + const rows = harness.messages.querySelectorAll(".assistant-mode-draft-row"); + assert.equal(rows.length, 4); + assert.deepEqual( + rows.map((row) => ({ + label: row.children[1].textContent, + value: row.children[2].textContent, + })), + [ + { label: "遮阳帘", value: "完全关闭(目标开度 0%)" }, + { label: "遮阳帘", value: "目标开度 30%" }, + { label: "遮阳帘", value: "半开(目标开度 50%)" }, + { label: "遮阳帘", value: "完全打开(目标开度 100%)" }, + ], + ); +}); + test("ordinary questions and updates for another mode do not carry the draft reference", async () => { const events = modeDraftEvents("req-draft-scoped", 24); const harness = createHarness(events); diff --git a/tests/unit/test_assistant_frontend_contract.py b/tests/unit/test_assistant_frontend_contract.py index d5eb664..8262dd9 100644 --- a/tests/unit/test_assistant_frontend_contract.py +++ b/tests/unit/test_assistant_frontend_contract.py @@ -13,15 +13,15 @@ def test_console_contains_ai_assistant_surface(): assert 'aria-label="AI助手对话"' in html assert 'placeholder="向 AI助手提问或交代工作"' in html assert 'aria-label="发送消息"' in html - assert 'href="/assistant-chat.css?v=codex-assistant-23"' in html - assert 'src="/assistant-chat.js?v=codex-assistant-23"' in html + assert 'href="/assistant-chat.css?v=codex-assistant-24"' in html + assert 'src="/assistant-chat.js?v=codex-assistant-24"' in html def test_codex_assistant_assets_are_versioned_together_in_the_service_worker(): service_worker = (ROOT / "apps/web-console/service-worker.js").read_text(encoding="utf-8") assert 'const CACHE_VERSION = "cognitive-agent-pad-v272"' in service_worker - assert '"/assistant-chat.css?v=codex-assistant-23"' in service_worker - assert '"/assistant-chat.js?v=codex-assistant-23"' in service_worker + assert '"/assistant-chat.css?v=codex-assistant-24"' in service_worker + assert '"/assistant-chat.js?v=codex-assistant-24"' in service_worker def test_codex_frontend_uses_one_streaming_endpoint_and_agent_work_events(): diff --git a/tests/unit/test_can1_semantic_whitelist.py b/tests/unit/test_can1_semantic_whitelist.py index 6d38d79..7cdf8da 100644 --- a/tests/unit/test_can1_semantic_whitelist.py +++ b/tests/unit/test_can1_semantic_whitelist.py @@ -349,6 +349,9 @@ class Can1SemanticWhitelistTests(unittest.TestCase): ("停止遮阳帘", ["shade stop"]), ("遮阳帘开到20%", ["shade position 20"]), ("遮阳帘打开30%", ["shade position 30"]), + ("遮阳帘调整为40%", ["shade position 40"]), + ("遮阳帘变成50%", ["shade position 50"]), + ("把遮阳帘换成100%", ["shade position 100"]), ) for index, (utterance, expected) in enumerate(cases): with self.subTest(utterance=utterance): diff --git a/tests/unit/test_codex_assistant_service.py b/tests/unit/test_codex_assistant_service.py index 843f4b5..c95e885 100644 --- a/tests/unit/test_codex_assistant_service.py +++ b/tests/unit/test_codex_assistant_service.py @@ -468,6 +468,49 @@ def test_pending_mode_draft_update_uses_fast_configuration_route_without_codex() assert "调整待保存" in result["answer"] +def test_pending_mode_draft_change_into_percentage_is_a_configuration_update() -> None: + context = { + "name": "观影模式", + "source_request_id": "req-viewing-preview", + "vehicle_id": "denza_n7", + } + + update = _authorize_vehicle_request( + "把观影模式的遮阳帘变成100%", + CONTROL_TERMS, + context, + ) + question = _authorize_vehicle_request( + "把观影模式的遮阳帘变成100%吗", + CONTROL_TERMS, + context, + ) + + assert update.reason == "explicit_mode_profile_draft_update" + assert question.reason == "mode_profile_query" + + +def test_pending_mode_draft_noop_answer_says_target_was_already_set() -> None: + result = _sample_mode_preview_result( + "观影模式", + generation_source="draft_revision", + ) + draft = result["mode_profile_draft"] + assert isinstance(draft, dict) + draft.update( + { + "requested_operation_count": 1, + "changed_operation_count": 0, + } + ) + + answer = CodexAssistantService._mode_profile_preview_answer(result, True) + + assert "该项原本已是目标状态" in answer + assert "已保持不变并重新校验" in answer + assert "已调整待保存" not in answer + + def test_active_draft_context_does_not_hijack_an_ordinary_question() -> None: client = FakeCodexClient() assistant = CodexAssistantService(client) @@ -1037,6 +1080,8 @@ def test_safe_vehicle_result_exposes_mode_preview_without_command_flow() -> None "vehicle_id": "denza_n7", "domains": ["roof", "interior_light", "ambient_light"], "operation_count": 4, + "requested_operation_count": 1, + "changed_operation_count": 0, "command_flow": {"raw_can": "must-not-cross-tool-boundary"}, }, "intent_ir": { @@ -1065,6 +1110,8 @@ def test_safe_vehicle_result_exposes_mode_preview_without_command_flow() -> None "vehicle_id": "denza_n7", "domains": ["roof", "interior_light", "ambient_light"], "operation_count": 4, + "requested_operation_count": 1, + "changed_operation_count": 0, } assert safe["actions"] == [ { diff --git a/tests/unit/test_energy_monitor_frontend_contract.py b/tests/unit/test_energy_monitor_frontend_contract.py index 65ceb11..41f2711 100644 --- a/tests/unit/test_energy_monitor_frontend_contract.py +++ b/tests/unit/test_energy_monitor_frontend_contract.py @@ -36,7 +36,7 @@ def test_energy_monitor_assets_are_versioned_and_cached() -> None: ): assert asset in html assert f'"{asset}"' in service_worker - assert html.index("/assistant-chat.js?v=codex-assistant-23") < html.index( + assert html.index("/assistant-chat.js?v=codex-assistant-24") < html.index( "/energy-monitor.js?v=energy-monitor-4" ) assert 'const CACHE_VERSION = "cognitive-agent-pad-v272"' in service_worker