fix: support conversational mode draft updates
This commit is contained in:
@@ -186,7 +186,10 @@ class FakeVehicleService:
|
||||
if self.fail:
|
||||
raise RuntimeError("private adapter failure")
|
||||
utterance = str(kwargs["utterance"])
|
||||
mode_preview = utterance.startswith(("请帮我创建一个", "构建一个"))
|
||||
draft_reference = kwargs.get("mode_profile_draft_context")
|
||||
mode_preview = utterance.startswith(("请帮我创建一个", "构建一个")) or isinstance(
|
||||
draft_reference, dict
|
||||
)
|
||||
callback = kwargs["on_commands_validated"]
|
||||
assert callable(callback)
|
||||
callback(
|
||||
@@ -199,7 +202,13 @@ class FakeVehicleService:
|
||||
)
|
||||
if mode_preview:
|
||||
movie_mode = "观影模式" in utterance
|
||||
mode_name = "观影模式" if movie_mode else "宝宝睡眠模式"
|
||||
mode_name = (
|
||||
str(draft_reference.get("name"))
|
||||
if isinstance(draft_reference, dict)
|
||||
else "观影模式"
|
||||
if movie_mode
|
||||
else "宝宝睡眠模式"
|
||||
)
|
||||
instruction = (
|
||||
"遮阳帘关闭,车窗关闭,副驾座椅调整为零重力模式,灯光全部关闭"
|
||||
if movie_mode
|
||||
@@ -297,6 +306,17 @@ class FakeVehicleService:
|
||||
"vehicle_id": "denza_n7",
|
||||
"domains": domains,
|
||||
"operation_count": len(actions),
|
||||
**(
|
||||
{
|
||||
"generation_source": "draft_revision",
|
||||
"draft_revision": 2,
|
||||
"revises_source_request_id": draft_reference[
|
||||
"source_request_id"
|
||||
],
|
||||
}
|
||||
if isinstance(draft_reference, dict)
|
||||
else {}
|
||||
),
|
||||
},
|
||||
"intent_ir": {"spec": {"actions": actions}},
|
||||
}
|
||||
@@ -690,6 +710,105 @@ class TestCodexAssistantWebApi:
|
||||
}
|
||||
]
|
||||
|
||||
def test_assistant_forwards_only_the_opaque_reference_for_pending_draft_update(
|
||||
self,
|
||||
) -> None:
|
||||
reference = {
|
||||
"name": "小憩模式",
|
||||
"source_request_id": "req-mode-preview",
|
||||
"vehicle_id": "denza_n7",
|
||||
}
|
||||
events = self.post_assistant(
|
||||
{
|
||||
"message": "把小憩模式的空调温度调整到25度",
|
||||
"conversation_id": "conv-api-update-pending-mode",
|
||||
"execution_mode": "hardware",
|
||||
"mode_profile_draft_context": reference,
|
||||
}
|
||||
)
|
||||
|
||||
assert not FAKE_CODEX.turn_calls
|
||||
call = AssistantApiHandler.service.calls[-1]
|
||||
assert call["utterance"] == "把小憩模式的空调温度调整到25度"
|
||||
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 (
|
||||
action["payload"]["mode_profile_draft"]["revises_source_request_id"]
|
||||
== "req-mode-preview"
|
||||
)
|
||||
assert events[0]["engine"] == "n7web-mode-builder"
|
||||
assert "调整待保存" in events[-1]["answer"]
|
||||
|
||||
def test_spaced_device_action_update_keeps_the_draft_route_end_to_end(
|
||||
self,
|
||||
) -> None:
|
||||
reference = {
|
||||
"name": "小憩模式",
|
||||
"source_request_id": "req-mode-preview",
|
||||
"vehicle_id": "denza_n7",
|
||||
}
|
||||
events = self.post_assistant(
|
||||
{
|
||||
"message": "请帮我 把 小憩模式 里的 遮阳帘关闭",
|
||||
"conversation_id": "conv-api-spaced-mode-update",
|
||||
"execution_mode": "hardware",
|
||||
"mode_profile_draft_context": reference,
|
||||
}
|
||||
)
|
||||
|
||||
assert not FAKE_CODEX.turn_calls
|
||||
call = AssistantApiHandler.service.calls[-1]
|
||||
assert call["mode_profile_draft_context"] == reference
|
||||
assert call["execution_mode"] == "hardware"
|
||||
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 events[0]["engine"] == "n7web-mode-builder"
|
||||
|
||||
def test_active_draft_reference_is_not_forwarded_for_an_unrelated_question(
|
||||
self,
|
||||
) -> None:
|
||||
events = self.post_assistant(
|
||||
{
|
||||
"message": "解释一下空调内循环和外循环的区别",
|
||||
"conversation_id": "conv-api-question-with-active-draft",
|
||||
"mode_profile_draft_context": {
|
||||
"name": "小憩模式",
|
||||
"source_request_id": "req-mode-preview",
|
||||
"vehicle_id": "denza_n7",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert events[0]["route"] == "agent"
|
||||
assert events[-1]["answer"] == "这是 Codex 的测试回答。"
|
||||
assert not AssistantApiHandler.service.calls
|
||||
|
||||
def test_mode_question_with_control_words_never_reaches_vehicle_service(
|
||||
self,
|
||||
) -> None:
|
||||
events = self.post_assistant(
|
||||
{
|
||||
"message": "修改小憩模式的空调设置会怎样?",
|
||||
"conversation_id": "conv-api-mode-question-no-control",
|
||||
"execution_mode": "hardware",
|
||||
"mode_profile_draft_context": {
|
||||
"name": "小憩模式",
|
||||
"source_request_id": "req-mode-preview",
|
||||
"vehicle_id": "denza_n7",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert events[0]["route"] == "agent"
|
||||
assert events[-1]["answer"] == "这是 Codex 的测试回答。"
|
||||
assert not AssistantApiHandler.service.calls
|
||||
|
||||
def test_exact_movie_mode_request_streams_a_nine_action_preview(self) -> None:
|
||||
events = self.post_assistant(
|
||||
{
|
||||
|
||||
@@ -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-22", timeout=3
|
||||
f"{self.base_url}/assistant-chat.js?v=codex-assistant-23", 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-22"', html)
|
||||
self.assertIn('src="/assistant-chat.js?v=codex-assistant-22"', 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="/energy-monitor.css?v=energy-monitor-4"', html)
|
||||
self.assertIn('src="/energy-monitor.js?v=energy-monitor-4"', html)
|
||||
self.assertNotIn('"admin"', console_script)
|
||||
@@ -992,11 +992,11 @@ operations:
|
||||
self.assertIn("CAN 指令已提交,等待车辆状态确认", console_script)
|
||||
self.assertIn('canvas.addEventListener("webglcontextlost"', app_script)
|
||||
self.assertIn('"cognitive-agent:module-change"', app_script)
|
||||
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v271"', service_worker)
|
||||
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-22', service_worker)
|
||||
self.assertIn('/assistant-chat.js?v=codex-assistant-22', 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('/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)
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from shutil import copytree
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -96,6 +97,9 @@ class MultiDomainServiceTests(unittest.TestCase):
|
||||
self.temporary_directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temporary_directory.cleanup)
|
||||
self.service.artifact_root = Path(self.temporary_directory.name)
|
||||
isolated_modes = Path(self.temporary_directory.name) / "modes"
|
||||
copytree(REPOSITORY_ROOT / "config" / "modes", isolated_modes)
|
||||
self.service.mode_profiles = ModeProfileRegistry(isolated_modes)
|
||||
|
||||
def test_driver_massage_mode_and_strength_continuations_compile_locally(
|
||||
self,
|
||||
|
||||
@@ -9,8 +9,13 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from cognitive_agent.application.codex_assistant import CodexAssistantService
|
||||
from cognitive_agent.application.mode_profile_intent import (
|
||||
is_mode_profile_query,
|
||||
parse_mode_profile_update,
|
||||
)
|
||||
from cognitive_agent.application.assistant_capabilities import AssistantCapabilityCatalog
|
||||
from cognitive_agent.application.mode_profile import ModeProfileRegistry
|
||||
from cognitive_agent.application.native_can_runtime import NativeCanCompileError
|
||||
from cognitive_agent.application.seat_service import (
|
||||
REPOSITORY_ROOT,
|
||||
SeatDemoService,
|
||||
@@ -130,6 +135,68 @@ def _intent_actions(result: dict[str, object]) -> list[dict[str, object]]:
|
||||
return actions
|
||||
|
||||
|
||||
def _draft_reference(result: dict[str, object]) -> dict[str, str]:
|
||||
draft = result["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 test_every_repository_mode_can_build_a_name_only_draft_without_exception(
|
||||
vehicle_service: SeatDemoService,
|
||||
) -> None:
|
||||
modes = vehicle_service.mode_profiles.public_modes("denza_n7")
|
||||
assert modes
|
||||
|
||||
for index, mode in enumerate(modes):
|
||||
labels = [str(mode["name"]), *map(str, mode.get("aliases", []))]
|
||||
label = next((item for item in labels if item.endswith("模式")), None)
|
||||
assert label is not None, mode["id"]
|
||||
result = vehicle_service.preview_native_can(
|
||||
f"构建一个{label}",
|
||||
provider="local",
|
||||
conversation_id=f"conv-name-only-mode-{index:02d}",
|
||||
execution_mode="hardware",
|
||||
vehicle_id="denza_n7",
|
||||
)
|
||||
|
||||
assert result["status"] == "succeeded", mode["id"]
|
||||
assert result["execution_mode"] == "dry_run"
|
||||
assert result["hardware_execution_allowed"] is False
|
||||
assert result["mode_profile_draft"]["operation_count"] > 0
|
||||
|
||||
|
||||
def test_name_only_mode_native_compile_exception_returns_scrubbed_failure(
|
||||
vehicle_service: SeatDemoService,
|
||||
) -> None:
|
||||
with patch.object(
|
||||
vehicle_service.native_can_runtime,
|
||||
"compile",
|
||||
side_effect=NativeCanCompileError("synthetic compiler rejection"),
|
||||
):
|
||||
result = vehicle_service.preview_native_can(
|
||||
"构建一个办公模式",
|
||||
provider="local",
|
||||
conversation_id="conv-name-only-native-compile-failure",
|
||||
execution_mode="hardware",
|
||||
vehicle_id="denza_n7",
|
||||
)
|
||||
|
||||
assert result["status"] == "validation_failed"
|
||||
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_draft") is None
|
||||
assert result["function_configs"] == {}
|
||||
assert result["command_plans"] == {}
|
||||
assert result["mode_creation_failure"]["code"] == (
|
||||
"MODE_PROFILE_NATIVE_COMPILE_FAILED"
|
||||
)
|
||||
|
||||
|
||||
def test_name_only_nap_request_reuses_the_verified_repository_profile(
|
||||
vehicle_service: SeatDemoService,
|
||||
tmp_path: Path,
|
||||
@@ -202,6 +269,537 @@ def test_inferred_nap_actions_are_bounded_by_the_runtime_capability_catalog(
|
||||
assert all("media" not in str(action["capability"]) for action in actions)
|
||||
|
||||
|
||||
def test_pending_nap_draft_temperature_can_be_revised_without_saving_or_execution(
|
||||
vehicle_service: SeatDemoService,
|
||||
) -> None:
|
||||
conversation_id = "conv-nap-pending-draft-revision"
|
||||
original_profile = vehicle_service.mode_profiles.get_profile("nap", "denza_n7")
|
||||
original_revision = vehicle_service.mode_profiles.profile_revision(original_profile)
|
||||
built = _build_nap_mode(vehicle_service, conversation_id)
|
||||
built_draft = built["mode_profile_draft"]
|
||||
assert isinstance(built_draft, dict)
|
||||
|
||||
revised = vehicle_service.preview_native_can(
|
||||
"把小憩模式的空调温度调整到25度",
|
||||
provider="local",
|
||||
conversation_id=conversation_id,
|
||||
execution_mode="hardware",
|
||||
vehicle_id="denza_n7",
|
||||
mode_profile_draft_context=_draft_reference(built),
|
||||
)
|
||||
|
||||
draft = revised["mode_profile_draft"]
|
||||
assert revised["status"] == "succeeded"
|
||||
assert revised["execution_mode"] == "dry_run"
|
||||
assert revised["hardware_execution_allowed"] is False
|
||||
assert revised["orchestration_route"] == "mode_profile_create_preview"
|
||||
assert draft["name"] == "小憩模式"
|
||||
assert draft["operation_count"] == built_draft["operation_count"] == 9
|
||||
assert draft["generation_source"] == "draft_revision"
|
||||
assert draft["draft_revision"] == 2
|
||||
assert draft["revises_source_request_id"] == built_draft["source_request_id"]
|
||||
assert draft["source_request_id"] != built_draft["source_request_id"]
|
||||
assert draft["existing_mode_id"] == "nap"
|
||||
assert draft["existing_mode_revision"] == original_revision
|
||||
revised_actions = _intent_actions(revised)
|
||||
temperatures = [
|
||||
action
|
||||
for action in revised_actions
|
||||
if action["capability"] == "climate.set_temperature"
|
||||
]
|
||||
assert len(temperatures) == 1
|
||||
assert temperatures[0]["parameters"]["temperature_c"] == 25
|
||||
assert (
|
||||
temperatures[0]["target"].get("zone")
|
||||
or temperatures[0]["target"].get("instance")
|
||||
) == "vehicle"
|
||||
def sibling_signature(action: dict[str, object]) -> str:
|
||||
parameters = dict(action.get("parameters") or {})
|
||||
if parameters.get("position_pct") is not None:
|
||||
parameters.pop("angle_deg", None)
|
||||
return json.dumps(
|
||||
{
|
||||
"domain": action.get("domain"),
|
||||
"capability": action.get("capability"),
|
||||
"target": action.get("target"),
|
||||
"parameters": parameters,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
)
|
||||
|
||||
assert {
|
||||
sibling_signature(action)
|
||||
for action in revised_actions
|
||||
if action["capability"] != "climate.set_temperature"
|
||||
} == {
|
||||
sibling_signature(action)
|
||||
for action in _intent_actions(built)
|
||||
if action["capability"] != "climate.set_temperature"
|
||||
}
|
||||
# Generating and revising a card is configuration-only. The registered
|
||||
# profile remains unchanged until the card's explicit save/update action.
|
||||
unchanged = vehicle_service.mode_profiles.get_profile("nap", "denza_n7")
|
||||
assert vehicle_service.mode_profiles.profile_revision(unchanged) == original_revision
|
||||
assert next(
|
||||
operation.parameters["temperature_c"]
|
||||
for operation in unchanged.operations
|
||||
if operation.capability == "climate.set_temperature"
|
||||
) != 25
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("message", "capability", "parameter", "expected"),
|
||||
(
|
||||
(
|
||||
"请帮我 把 小憩模式 的 空调温度调整到25度",
|
||||
"climate.set_temperature",
|
||||
"temperature_c",
|
||||
25,
|
||||
),
|
||||
(
|
||||
"把小憩模式中的空调温度调整到25度",
|
||||
"climate.set_temperature",
|
||||
"temperature_c",
|
||||
25,
|
||||
),
|
||||
(
|
||||
"把小憩模式里空调温度设置为25度",
|
||||
"climate.set_temperature",
|
||||
"temperature_c",
|
||||
25,
|
||||
),
|
||||
(
|
||||
"小憩模式,空调温度调到25度。",
|
||||
"climate.set_temperature",
|
||||
"temperature_c",
|
||||
25,
|
||||
),
|
||||
(
|
||||
"把小憩模式的遮阳帘关闭",
|
||||
"roof.set_shade_position",
|
||||
"position_pct",
|
||||
0,
|
||||
),
|
||||
(
|
||||
"把小憩模式里的氛围灯打开",
|
||||
"ambient_light.set_power",
|
||||
"state",
|
||||
"on",
|
||||
),
|
||||
),
|
||||
)
|
||||
def test_pending_draft_update_variants_never_enter_physical_execution(
|
||||
vehicle_service: SeatDemoService,
|
||||
message: str,
|
||||
capability: str,
|
||||
parameter: str,
|
||||
expected: object,
|
||||
) -> None:
|
||||
conversation_id = "conv-nap-draft-variant-" + str(abs(hash(message)))[:12]
|
||||
built = _build_nap_mode(vehicle_service, conversation_id)
|
||||
|
||||
with patch.object(
|
||||
vehicle_service.hardware_execution,
|
||||
"reserve",
|
||||
side_effect=AssertionError("pending draft update reached hardware lease"),
|
||||
):
|
||||
revised = vehicle_service.preview_native_can(
|
||||
message,
|
||||
provider="local",
|
||||
conversation_id=conversation_id,
|
||||
execution_mode="hardware",
|
||||
vehicle_id="denza_n7",
|
||||
mode_profile_draft_context=_draft_reference(built),
|
||||
)
|
||||
|
||||
assert revised["status"] == "succeeded"
|
||||
assert revised["execution_mode"] == "dry_run"
|
||||
assert revised["hardware_execution_allowed"] is False
|
||||
assert revised["orchestration_route"] == "mode_profile_create_preview"
|
||||
assert next(
|
||||
action["parameters"][parameter]
|
||||
for action in _intent_actions(revised)
|
||||
if action["capability"] == capability
|
||||
) == expected
|
||||
|
||||
|
||||
def test_pending_draft_can_be_revised_repeatedly_from_latest_server_artifact(
|
||||
vehicle_service: SeatDemoService,
|
||||
) -> None:
|
||||
conversation_id = "conv-nap-pending-draft-chain"
|
||||
built = _build_nap_mode(vehicle_service, conversation_id)
|
||||
second = vehicle_service.preview_native_can(
|
||||
"把小憩模式的空调温度调整到25度",
|
||||
provider="local",
|
||||
conversation_id=conversation_id,
|
||||
vehicle_id="denza_n7",
|
||||
mode_profile_draft_context=_draft_reference(built),
|
||||
)
|
||||
third = vehicle_service.preview_native_can(
|
||||
"把小憩模式的空调温度调整到26度",
|
||||
provider="local",
|
||||
conversation_id=conversation_id,
|
||||
vehicle_id="denza_n7",
|
||||
mode_profile_draft_context=_draft_reference(second),
|
||||
)
|
||||
|
||||
second_draft = second["mode_profile_draft"]
|
||||
third_draft = third["mode_profile_draft"]
|
||||
assert third_draft["draft_revision"] == 3
|
||||
assert third_draft["revises_source_request_id"] == second_draft["source_request_id"]
|
||||
assert next(
|
||||
action["parameters"]["temperature_c"]
|
||||
for action in _intent_actions(third)
|
||||
if action["capability"] == "climate.set_temperature"
|
||||
) == 26
|
||||
|
||||
|
||||
def test_pending_draft_reference_is_bound_to_conversation_name_and_vehicle(
|
||||
vehicle_service: SeatDemoService,
|
||||
) -> None:
|
||||
built = _build_nap_mode(vehicle_service, "conv-nap-reference-owner")
|
||||
reference = _draft_reference(built)
|
||||
|
||||
wrong_conversation = vehicle_service.preview_native_can(
|
||||
"把小憩模式的空调温度调整到25度",
|
||||
provider="local",
|
||||
conversation_id="conv-nap-reference-attacker",
|
||||
vehicle_id="denza_n7",
|
||||
mode_profile_draft_context=reference,
|
||||
)
|
||||
assert wrong_conversation["status"] == "needs_confirmation"
|
||||
assert wrong_conversation.get("mode_profile_draft") is None
|
||||
assert any(
|
||||
issue.get("code") == "MODE_PROFILE_DRAFT_UPDATE_REJECTED"
|
||||
for issue in wrong_conversation["validation"]
|
||||
)
|
||||
|
||||
for invalid_reference in (
|
||||
{**reference, "vehicle_id": "another_vehicle"},
|
||||
{**reference, "source_request_id": "req-forged-preview"},
|
||||
):
|
||||
rejected = vehicle_service.preview_native_can(
|
||||
"把小憩模式的空调温度调整到25度",
|
||||
provider="local",
|
||||
conversation_id="conv-nap-reference-owner",
|
||||
vehicle_id="denza_n7",
|
||||
mode_profile_draft_context=invalid_reference,
|
||||
)
|
||||
assert rejected["status"] == "needs_confirmation"
|
||||
assert rejected.get("mode_profile_draft") is None
|
||||
assert rejected["hardware_execution_allowed"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"message",
|
||||
(
|
||||
"把小憩模式的空调介绍一下",
|
||||
"小憩模式的空调有什么作用",
|
||||
"小憩模式的空调和普通空调有什么区别",
|
||||
"小憩模式的空调设置有哪些?",
|
||||
"小憩模式的空调该调几度",
|
||||
"把小憩模式的空调调到25度合适吗",
|
||||
"调整小憩模式的空调参数,列出来",
|
||||
),
|
||||
)
|
||||
def test_air_conditioner_name_alone_is_not_parsed_as_mode_profile_update(
|
||||
message: str,
|
||||
) -> None:
|
||||
assert SeatDemoService._parse_mode_profile_update(message) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"message",
|
||||
(
|
||||
"空调出风模式设置为吹面和吹脚",
|
||||
"把空调出风模式设置为吹面和吹脚",
|
||||
"调整空调出风模式为吹面和吹脚",
|
||||
"主驾座椅按摩模式设置为放松",
|
||||
"副驾座椅零重力模式设为开启",
|
||||
"进入动态摇篮模式,空调24度,打开遮阳帘",
|
||||
"开启女王副驾模式,天幕透光率调到20%",
|
||||
),
|
||||
)
|
||||
def test_device_property_mode_is_not_a_named_mode_profile_update(
|
||||
message: str,
|
||||
) -> None:
|
||||
assert SeatDemoService._parse_mode_profile_update(message) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("message", "expected"),
|
||||
(
|
||||
("把小憩模式的空调温度调整到25度", ("小憩模式", "空调温度调整到25度")),
|
||||
("午休模式里的遮阳帘关闭", ("午休模式", "遮阳帘关闭")),
|
||||
("修改露营模式,天幕透光率改到50%", ("露营模式", "天幕透光率改到50%")),
|
||||
),
|
||||
)
|
||||
def test_named_mode_profile_update_requires_an_explicit_member_boundary(
|
||||
message: str,
|
||||
expected: tuple[str, str],
|
||||
) -> None:
|
||||
assert SeatDemoService._parse_mode_profile_update(message) == expected
|
||||
|
||||
|
||||
def test_name_first_comma_update_requires_the_active_draft_name() -> None:
|
||||
message = "小憩模式,空调温度调到25度。"
|
||||
|
||||
assert SeatDemoService._parse_mode_profile_update(message) is None
|
||||
assert parse_mode_profile_update(
|
||||
message,
|
||||
expected_mode_name="小憩模式",
|
||||
) == ("小憩模式", "空调温度调到25度")
|
||||
assert parse_mode_profile_update(
|
||||
message,
|
||||
expected_mode_name="露营模式",
|
||||
) is None
|
||||
|
||||
|
||||
def test_name_first_comma_question_is_not_a_draft_update() -> None:
|
||||
message = "小憩模式,空调温度调到25度合适吗?"
|
||||
|
||||
assert parse_mode_profile_update(
|
||||
message,
|
||||
expected_mode_name="小憩模式",
|
||||
) is None
|
||||
assert is_mode_profile_query(
|
||||
message,
|
||||
expected_mode_name="小憩模式",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"message",
|
||||
(
|
||||
"修改小憩模式的空调设置是什么",
|
||||
"小憩模式的空调该调几度",
|
||||
"把小憩模式的空调调到25度合适吗",
|
||||
"调整小憩模式的空调参数,列出来",
|
||||
"小憩模式的空调调到25度会怎样",
|
||||
),
|
||||
)
|
||||
def test_mode_profile_query_is_fail_closed_even_on_direct_hardware_service_call(
|
||||
vehicle_service: SeatDemoService,
|
||||
message: str,
|
||||
) -> None:
|
||||
with patch.object(
|
||||
vehicle_service.hardware_execution,
|
||||
"reserve",
|
||||
side_effect=AssertionError("mode-profile query reached hardware lease"),
|
||||
):
|
||||
result = vehicle_service.preview_native_can(
|
||||
message,
|
||||
provider="local",
|
||||
conversation_id="conv-mode-profile-query-guard",
|
||||
execution_mode="hardware",
|
||||
vehicle_id="denza_n7",
|
||||
)
|
||||
|
||||
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["function_configs"] == {}
|
||||
assert result["command_plans"] == {}
|
||||
|
||||
|
||||
def test_pending_draft_rejects_unsafe_change_without_returning_a_partial_card(
|
||||
vehicle_service: SeatDemoService,
|
||||
) -> None:
|
||||
conversation_id = "conv-nap-pending-invalid-change"
|
||||
built = _build_nap_mode(vehicle_service, conversation_id)
|
||||
rejected = vehicle_service.preview_native_can(
|
||||
"把小憩模式的空调温度调整到99度",
|
||||
provider="local",
|
||||
conversation_id=conversation_id,
|
||||
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.get("mode_profile_draft") is None
|
||||
assert rejected["function_configs"] == {}
|
||||
assert rejected["command_plans"] == {}
|
||||
|
||||
|
||||
def test_pending_draft_revision_fails_closed_if_full_plan_loses_any_action(
|
||||
vehicle_service: SeatDemoService,
|
||||
) -> None:
|
||||
conversation_id = "conv-nap-pending-incomplete-revision"
|
||||
built = _build_nap_mode(vehicle_service, conversation_id)
|
||||
|
||||
with patch.object(
|
||||
vehicle_service,
|
||||
"_scene_action_signatures",
|
||||
return_value={},
|
||||
):
|
||||
rejected = vehicle_service.preview_native_can(
|
||||
"把小憩模式的空调温度调整到25度",
|
||||
provider="local",
|
||||
conversation_id=conversation_id,
|
||||
vehicle_id="denza_n7",
|
||||
mode_profile_draft_context=_draft_reference(built),
|
||||
)
|
||||
|
||||
assert rejected["status"] != "succeeded"
|
||||
assert rejected.get("mode_profile_draft") is None
|
||||
assert not _intent_actions(rejected)
|
||||
assert rejected["function_configs"] == {}
|
||||
assert rejected["command_plans"] == {}
|
||||
assert any(
|
||||
issue.get("code") == "MODE_PROFILE_SCENE_PLAN_INCOMPLETE"
|
||||
for issue in rejected["validation"]
|
||||
)
|
||||
|
||||
|
||||
def test_pending_draft_update_rejects_partially_understood_change_atomically(
|
||||
vehicle_service: SeatDemoService,
|
||||
) -> None:
|
||||
conversation_id = "conv-nap-pending-partial-change"
|
||||
built = _build_nap_mode(vehicle_service, conversation_id)
|
||||
rejected = vehicle_service.preview_native_can(
|
||||
"把小憩模式的空调温度调整到25度,同时启动曲速引擎",
|
||||
provider="local",
|
||||
conversation_id=conversation_id,
|
||||
vehicle_id="denza_n7",
|
||||
mode_profile_draft_context=_draft_reference(built),
|
||||
)
|
||||
|
||||
assert rejected["status"] == "needs_confirmation"
|
||||
assert rejected.get("mode_profile_draft") is None
|
||||
assert rejected["function_configs"] == {}
|
||||
assert rejected["command_plans"] == {}
|
||||
assert "未完整识别" in rejected["mode_creation_failure"]["reason"]
|
||||
|
||||
|
||||
def test_revised_unknown_mode_draft_can_be_trialed_and_saved_with_all_actions(
|
||||
vehicle_service: SeatDemoService,
|
||||
) -> None:
|
||||
conversation_id = "conv-custom-pending-save-after-revision"
|
||||
built = vehicle_service.preview_native_can(
|
||||
"构建一个午休模式",
|
||||
provider="local",
|
||||
conversation_id=conversation_id,
|
||||
vehicle_id="denza_n7",
|
||||
assistant_plan=LUNCH_BREAK_SCENE_PLAN,
|
||||
)
|
||||
revised = vehicle_service.preview_native_can(
|
||||
"午休模式的空调温度改为25度",
|
||||
provider="local",
|
||||
conversation_id=conversation_id,
|
||||
vehicle_id="denza_n7",
|
||||
mode_profile_draft_context=_draft_reference(built),
|
||||
)
|
||||
draft = revised["mode_profile_draft"]
|
||||
|
||||
trial = vehicle_service.trial_mode_profile_draft(
|
||||
str(draft["name"]),
|
||||
str(draft["instruction"]),
|
||||
str(draft["source_request_id"]),
|
||||
str(draft["vehicle_id"]),
|
||||
)
|
||||
saved = vehicle_service.create_mode_profile_card(
|
||||
str(draft["name"]),
|
||||
str(draft["instruction"]),
|
||||
str(draft["source_request_id"]),
|
||||
str(draft["vehicle_id"]),
|
||||
)
|
||||
|
||||
assert trial["status"] == "succeeded"
|
||||
assert trial["mode_profile_draft"]["draft_revision"] == 2
|
||||
assert len(_intent_actions(trial)) == draft["operation_count"] == 5
|
||||
assert saved["mode_profile"]["operation_count"] == 5
|
||||
profile = vehicle_service.mode_profiles.get_profile(
|
||||
str(saved["mode_profile"]["id"]),
|
||||
"denza_n7",
|
||||
)
|
||||
assert next(
|
||||
operation.parameters["temperature_c"]
|
||||
for operation in profile.operations
|
||||
if operation.capability == "climate.set_temperature"
|
||||
) == 25
|
||||
|
||||
|
||||
def test_explicit_mode_with_close_motions_can_still_receive_a_temperature_revision(
|
||||
vehicle_service: SeatDemoService,
|
||||
) -> None:
|
||||
conversation_id = "conv-explicit-motion-draft-revision"
|
||||
built = vehicle_service.preview_native_can(
|
||||
"创建一个躺平测试模式,副驾座椅靠背向后移动到最大位置,"
|
||||
"遮阳帘关闭,车窗关闭,灯光全部关闭,空调调整到24度",
|
||||
provider="local",
|
||||
conversation_id=conversation_id,
|
||||
vehicle_id="denza_n7",
|
||||
)
|
||||
assert built["status"] == "succeeded"
|
||||
assert built["mode_profile_draft"]["operation_count"] == 7
|
||||
|
||||
revised = vehicle_service.preview_native_can(
|
||||
"把躺平测试模式的空调温度调整到25度",
|
||||
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"] == len(actions) == 7
|
||||
assert next(
|
||||
action["parameters"]["temperature_c"]
|
||||
for action in actions
|
||||
if action["capability"] == "climate.set_temperature"
|
||||
) == 25
|
||||
capabilities = {str(action["capability"]) for action in actions}
|
||||
assert "roof.set_shade_position" in capabilities
|
||||
assert "window.set_position" in capabilities
|
||||
assert "roof.set_shade_motion" not in capabilities
|
||||
assert "window.set_motion" not in capabilities
|
||||
|
||||
|
||||
def test_unscoped_change_rejects_a_draft_with_multiple_targets_for_same_capability(
|
||||
vehicle_service: SeatDemoService,
|
||||
) -> None:
|
||||
conversation_id = "conv-multi-zone-pending-draft"
|
||||
multi_zone_plan = {
|
||||
**LUNCH_BREAK_SCENE_PLAN,
|
||||
"detected_domains": ["climate"],
|
||||
"requests": [
|
||||
_scene_request(
|
||||
"climate", "climate.set_temperature", "driver", {"temperature_c": 23}
|
||||
),
|
||||
_scene_request(
|
||||
"climate", "climate.set_temperature", "passenger", {"temperature_c": 24}
|
||||
),
|
||||
],
|
||||
}
|
||||
built = vehicle_service.preview_native_can(
|
||||
"构建一个双区舒适模式",
|
||||
provider="local",
|
||||
conversation_id=conversation_id,
|
||||
vehicle_id="denza_n7",
|
||||
assistant_plan=multi_zone_plan,
|
||||
)
|
||||
assert built["status"] == "succeeded"
|
||||
|
||||
rejected = vehicle_service.preview_native_can(
|
||||
"把双区舒适模式的空调温度调整到25度",
|
||||
provider="local",
|
||||
conversation_id=conversation_id,
|
||||
vehicle_id="denza_n7",
|
||||
mode_profile_draft_context=_draft_reference(built),
|
||||
)
|
||||
assert rejected["status"] == "needs_confirmation"
|
||||
assert rejected.get("mode_profile_draft") is None
|
||||
assert "多个目标" in rejected["mode_creation_failure"]["reason"]
|
||||
|
||||
|
||||
def test_codex_composes_an_unregistered_name_only_scene_from_runtime_capabilities(
|
||||
vehicle_service: SeatDemoService,
|
||||
tmp_path: Path,
|
||||
|
||||
@@ -72,6 +72,11 @@ class MockElement extends MockEventTarget {
|
||||
this.value = "";
|
||||
}
|
||||
|
||||
get className() { return [...this.classList.values].join(" "); }
|
||||
set className(value) {
|
||||
this.classList.values = new Set(String(value).split(/\s+/).filter(Boolean));
|
||||
}
|
||||
|
||||
append(...nodes) {
|
||||
nodes.forEach((node) => {
|
||||
this.children.push(node);
|
||||
@@ -79,8 +84,31 @@ class MockElement extends MockEventTarget {
|
||||
});
|
||||
}
|
||||
|
||||
closest(selector) {
|
||||
if (selector.startsWith("#") && this.id === selector.slice(1)) return this;
|
||||
return this.parentElement?.closest?.(selector) || null;
|
||||
}
|
||||
focus() {}
|
||||
querySelector() { return null; }
|
||||
matches(selector) {
|
||||
if (selector === "button") return this.tagName === "BUTTON";
|
||||
if (selector.startsWith(".")) return this.classList.contains(selector.slice(1));
|
||||
const dataSelector = selector.match(/^\[data-([a-z-]+)(?:="([^"]*)")?\]$/);
|
||||
if (!dataSelector) return false;
|
||||
const key = dataSelector[1].replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase());
|
||||
if (!(key in this.dataset)) return false;
|
||||
return dataSelector[2] === undefined || this.dataset[key] === dataSelector[2];
|
||||
}
|
||||
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
|
||||
querySelectorAll(selector) {
|
||||
const matches = [];
|
||||
const visit = (node) => {
|
||||
if (!(node instanceof MockElement)) return;
|
||||
if (node.matches(selector)) matches.push(node);
|
||||
node.children.forEach(visit);
|
||||
};
|
||||
this.children.forEach(visit);
|
||||
return matches;
|
||||
}
|
||||
replaceChildren(...nodes) {
|
||||
this.children = [];
|
||||
this.append(...nodes);
|
||||
@@ -133,7 +161,9 @@ function createHarness(events) {
|
||||
document.createTextNode = (text) => ({ nodeType: 3, textContent: String(text) });
|
||||
document.querySelector = (selector) => selectors.get(selector) || null;
|
||||
document.querySelectorAll = (selector) => (
|
||||
selector === "#assistantMessages [data-message-role]" ? [...messages.children] : []
|
||||
selector === "#assistantMessages [data-message-role]"
|
||||
? [...messages.children]
|
||||
: messages.querySelectorAll(selector)
|
||||
);
|
||||
|
||||
const storage = new Map();
|
||||
@@ -181,6 +211,15 @@ function createHarness(events) {
|
||||
sandbox.CognitiveAgentNetwork = {
|
||||
async request(url, options) {
|
||||
requestCalls.push({ url, options });
|
||||
if (url === "/api/mode-profiles" || url.startsWith("/api/mode-profiles/")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
async json() {
|
||||
return { mode_profile: { id: "mode-saved", name: "午休模式" } };
|
||||
},
|
||||
};
|
||||
}
|
||||
return ndjsonResponse(events);
|
||||
},
|
||||
};
|
||||
@@ -234,3 +273,177 @@ test("a completed NDJSON answer remains the authoritative visible response", asy
|
||||
assert.equal(harness.completed.length, 1);
|
||||
assert.equal(harness.completed[0].error, null);
|
||||
});
|
||||
|
||||
function modeDraftEvents(sourceRequestId, temperatureC) {
|
||||
return [
|
||||
{ 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: `空调调整到${temperatureC}度`,
|
||||
operation_count: 1,
|
||||
source_request_id: sourceRequestId,
|
||||
vehicle_id: "demo-vehicle",
|
||||
},
|
||||
actions: [{
|
||||
domain: "climate",
|
||||
capability: "climate.set_temperature",
|
||||
target: { zone: "all" },
|
||||
parameters: { temperature_c: temperatureC },
|
||||
}],
|
||||
},
|
||||
},
|
||||
{ type: "answer.completed", route: "vehicle_control", answer: "模式草稿已生成" },
|
||||
];
|
||||
}
|
||||
|
||||
test("a follow-up turn carries only the active authoritative draft reference", async () => {
|
||||
const events = modeDraftEvents("req-draft-one", 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();
|
||||
|
||||
const request = JSON.parse(harness.requestCalls[1].options.body);
|
||||
assert.equal(request.mode_profile_draft_context.name, "午休模式");
|
||||
assert.equal(request.mode_profile_draft_context.source_request_id, "req-draft-one");
|
||||
assert.equal(request.mode_profile_draft_context.vehicle_id, "demo-vehicle");
|
||||
assert.equal(Object.hasOwn(request.mode_profile_draft_context, "actions"), false);
|
||||
});
|
||||
|
||||
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);
|
||||
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 = "空调制冷原理是什么";
|
||||
await harness.sandbox.CognitiveAgentAssistant.submit();
|
||||
harness.input.value = "把露营模式的空调温度调整到25度";
|
||||
await harness.sandbox.CognitiveAgentAssistant.submit();
|
||||
|
||||
const ordinary = JSON.parse(harness.requestCalls[1].options.body);
|
||||
const anotherMode = JSON.parse(harness.requestCalls[2].options.body);
|
||||
assert.equal(Object.hasOwn(ordinary, "mode_profile_draft_context"), false);
|
||||
assert.equal(Object.hasOwn(anotherMode, "mode_profile_draft_context"), false);
|
||||
});
|
||||
|
||||
test("device action predicates carry context while mode questions stay ordinary", async () => {
|
||||
const events = modeDraftEvents("req-draft-actions", 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 = "请帮我把午休模式里的遮阳帘关闭";
|
||||
await harness.sandbox.CognitiveAgentAssistant.submit();
|
||||
harness.input.value = "把午休模式的车窗全关";
|
||||
await harness.sandbox.CognitiveAgentAssistant.submit();
|
||||
harness.input.value = "午休模式的空调设置有哪些?";
|
||||
await harness.sandbox.CognitiveAgentAssistant.submit();
|
||||
|
||||
const deviceAction = JSON.parse(harness.requestCalls[1].options.body);
|
||||
const secondDeviceAction = JSON.parse(harness.requestCalls[2].options.body);
|
||||
const ordinaryQuestion = JSON.parse(harness.requestCalls[3].options.body);
|
||||
assert.equal(deviceAction.mode_profile_draft_context.source_request_id, "req-draft-actions");
|
||||
assert.equal(secondDeviceAction.mode_profile_draft_context.source_request_id, "req-draft-actions");
|
||||
assert.equal(Object.hasOwn(ordinaryQuestion, "mode_profile_draft_context"), false);
|
||||
});
|
||||
|
||||
test("a replacement draft disables the stale same-name card", async () => {
|
||||
const events = modeDraftEvents("req-draft-old", 24);
|
||||
const harness = createHarness(events);
|
||||
harness.input.value = "构建一个午休模式";
|
||||
await harness.sandbox.CognitiveAgentAssistant.submit();
|
||||
|
||||
events.splice(0, events.length, ...modeDraftEvents("req-draft-new", 25));
|
||||
harness.input.value = "把午休模式的空调温度调整到25度";
|
||||
await harness.sandbox.CognitiveAgentAssistant.submit();
|
||||
|
||||
const oldCard = harness.messages.querySelector(
|
||||
'[data-assistant-mode-draft="req-draft-old"]',
|
||||
);
|
||||
const newCard = harness.messages.querySelector(
|
||||
'[data-assistant-mode-draft="req-draft-new"]',
|
||||
);
|
||||
assert.ok(oldCard);
|
||||
assert.ok(newCard);
|
||||
assert.equal(oldCard.classList.contains("is-superseded"), true);
|
||||
oldCard.querySelectorAll("button").forEach((button) => assert.equal(button.disabled, true));
|
||||
assert.equal(
|
||||
oldCard.children.at(-1).textContent,
|
||||
"已由下方最新草稿替换",
|
||||
);
|
||||
});
|
||||
|
||||
test("saving the replacement card uses its latest request and clears follow-up context", async () => {
|
||||
const events = modeDraftEvents("req-draft-before-save", 24);
|
||||
const harness = createHarness(events);
|
||||
harness.input.value = "构建一个午休模式";
|
||||
await harness.sandbox.CognitiveAgentAssistant.submit();
|
||||
events.splice(0, events.length, ...modeDraftEvents("req-draft-after-edit", 25));
|
||||
harness.input.value = "把午休模式的空调温度调整到25度";
|
||||
await harness.sandbox.CognitiveAgentAssistant.submit();
|
||||
|
||||
const latestCard = harness.messages.querySelector(
|
||||
'[data-assistant-mode-draft="req-draft-after-edit"]',
|
||||
);
|
||||
latestCard.querySelectorAll("button")[0].dispatchEvent({ type: "click" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const saveCall = harness.requestCalls.find((call) => call.url === "/api/mode-profiles");
|
||||
assert.ok(saveCall);
|
||||
assert.equal(JSON.parse(saveCall.options.body).source_request_id, "req-draft-after-edit");
|
||||
|
||||
events.splice(0, events.length,
|
||||
{ type: "message.accepted", route: "agent", engine: "codex-app-server" },
|
||||
{ type: "answer.completed", route: "agent", answer: "已保存后的回答" },
|
||||
);
|
||||
harness.input.value = "把午休模式的空调温度调整到26度";
|
||||
await harness.sandbox.CognitiveAgentAssistant.submit();
|
||||
const finalStreamCall = harness.requestCalls.filter(
|
||||
(call) => call.url === "/api/assistant/stream",
|
||||
).at(-1);
|
||||
assert.equal(
|
||||
Object.hasOwn(JSON.parse(finalStreamCall.options.body), "mode_profile_draft_context"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("starting a new conversation clears the active draft reference", async () => {
|
||||
const events = modeDraftEvents("req-draft-reset", 24);
|
||||
const harness = createHarness(events);
|
||||
harness.input.value = "构建一个午休模式";
|
||||
await harness.sandbox.CognitiveAgentAssistant.submit();
|
||||
|
||||
const reset = new MockElement("button");
|
||||
reset.id = "newConversation";
|
||||
harness.sandbox.document.dispatchEvent({ type: "click", target: reset });
|
||||
events.splice(0, events.length,
|
||||
{ type: "message.accepted", route: "agent", engine: "codex-app-server" },
|
||||
{ type: "answer.completed", route: "agent", answer: "新的对话" },
|
||||
);
|
||||
harness.input.value = "普通问题";
|
||||
await harness.sandbox.CognitiveAgentAssistant.submit();
|
||||
|
||||
const request = JSON.parse(harness.requestCalls[1].options.body);
|
||||
assert.equal(Object.hasOwn(request, "mode_profile_draft_context"), false);
|
||||
});
|
||||
|
||||
@@ -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-22"' in html
|
||||
assert 'src="/assistant-chat.js?v=codex-assistant-22"' in html
|
||||
assert 'href="/assistant-chat.css?v=codex-assistant-23"' in html
|
||||
assert 'src="/assistant-chat.js?v=codex-assistant-23"' 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-v271"' in service_worker
|
||||
assert '"/assistant-chat.css?v=codex-assistant-22"' in service_worker
|
||||
assert '"/assistant-chat.js?v=codex-assistant-22"' in service_worker
|
||||
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
|
||||
|
||||
|
||||
def test_codex_frontend_uses_one_streaming_endpoint_and_agent_work_events():
|
||||
@@ -157,6 +157,55 @@ def test_assistant_mode_creation_previews_then_saves_to_the_compact_mode_strip()
|
||||
assert ".assistant-mode-created" in css
|
||||
|
||||
|
||||
def test_assistant_carries_only_the_active_mode_draft_reference_to_follow_up_turns():
|
||||
assistant = (ROOT / "apps/web-console/assistant-chat.js").read_text(encoding="utf-8")
|
||||
request_body = assistant.split("function requestBody(message)", 1)[1].split(
|
||||
"async function readErrorResponse", 1
|
||||
)[0]
|
||||
context_flow = assistant.split("function sanitizeModeDraftContext", 1)[1].split(
|
||||
"function loadStoredMessages", 1
|
||||
)[0]
|
||||
|
||||
assert (
|
||||
'const ACTIVE_MODE_DRAFT_STORAGE_KEY = "cognitive-agent.active-mode-draft"'
|
||||
in assistant
|
||||
)
|
||||
assert "mode_profile_draft_context: modeProfileDraftContext" in request_body
|
||||
assert "requestModeDraftContext(message)" in request_body
|
||||
assert "name: context.name" in context_flow
|
||||
assert "source_request_id: context.source_request_id" in context_flow
|
||||
assert "vehicle_id: context.vehicle_id" in context_flow
|
||||
assert "actions" not in context_flow
|
||||
assert "rememberModeDraftContext(draft)" in assistant
|
||||
assert "activeModeDraftContext = loadActiveModeDraftContext()" in assistant
|
||||
assert "function isModeDraftUpdateRequest(message, context)" in assistant
|
||||
assert "normalizeModeName(matched[1]) === normalizeModeName(context.name)" in assistant
|
||||
|
||||
|
||||
def test_latest_same_name_mode_draft_retires_stale_card_and_lifecycle_clears_context():
|
||||
assistant = (ROOT / "apps/web-console/assistant-chat.js").read_text(encoding="utf-8")
|
||||
css = (ROOT / "apps/web-console/assistant-chat.css").read_text(encoding="utf-8")
|
||||
replace_flow = assistant.split("function supersedeModeDraftCards", 1)[1].split(
|
||||
"function appendModeDraftState", 1
|
||||
)[0]
|
||||
save_flow = assistant.split("async function saveModeProfileDraft", 1)[1].split(
|
||||
"async function tryModeProfileDraft", 1
|
||||
)[0]
|
||||
new_conversation_flow = assistant.split(
|
||||
'if (!target?.closest("#newConversation")) return;', 1
|
||||
)[1].split("}, true);", 1)[0]
|
||||
|
||||
assert "card.dataset.assistantModeName !== nextName" in replace_flow
|
||||
assert "card.dataset.assistantModeDraft === nextRequestId" in replace_flow
|
||||
assert 'card.classList.add("is-superseded")' in replace_flow
|
||||
assert "button.disabled = true" in replace_flow
|
||||
assert "已由下方最新草稿替换" in replace_flow
|
||||
assert "clearActiveModeDraftContext(draft.source_request_id)" in save_flow
|
||||
assert "clearActiveModeDraftContext()" in new_conversation_flow
|
||||
assert ".assistant-mode-draft.is-superseded" in css
|
||||
assert ".assistant-mode-draft-status.superseded" in css
|
||||
|
||||
|
||||
def test_assistant_composer_grows_to_a_cap_then_scrolls_its_own_content():
|
||||
assistant = (ROOT / "apps/web-console/assistant-chat.js").read_text(encoding="utf-8")
|
||||
css = (ROOT / "apps/web-console/assistant-chat.css").read_text(encoding="utf-8")
|
||||
|
||||
@@ -341,6 +341,7 @@ def _stream(
|
||||
execute_control: Callable[..., dict[str, object]] | None = None,
|
||||
capability_snapshot: dict[str, object] | None = None,
|
||||
capability_context: dict[str, object] | None = None,
|
||||
mode_profile_draft_context: dict[str, object] | None = None,
|
||||
) -> tuple[dict[str, object], list[dict[str, object]]]:
|
||||
events: list[dict[str, object]] = []
|
||||
result = assistant.stream(
|
||||
@@ -354,6 +355,7 @@ def _stream(
|
||||
control_terms=CONTROL_TERMS,
|
||||
capability_snapshot=capability_snapshot,
|
||||
capability_context=capability_context,
|
||||
mode_profile_draft_context=mode_profile_draft_context,
|
||||
)
|
||||
return result, events
|
||||
|
||||
@@ -426,6 +428,181 @@ def test_registered_name_only_mode_keeps_the_fast_local_route_without_codex() ->
|
||||
assert events[0]["engine"] == "n7web-mode-builder"
|
||||
|
||||
|
||||
def test_pending_mode_draft_update_uses_fast_configuration_route_without_codex() -> None:
|
||||
client = FakeCodexClient(available=False)
|
||||
assistant = CodexAssistantService(client)
|
||||
calls: list[str] = []
|
||||
result_payload = _sample_mode_preview_result(
|
||||
"小憩模式",
|
||||
generation_source="draft_revision",
|
||||
existing_mode_id="nap",
|
||||
)
|
||||
draft = result_payload["mode_profile_draft"]
|
||||
assert isinstance(draft, dict)
|
||||
draft.update(
|
||||
{
|
||||
"source_request_id": "req-mode-revision",
|
||||
"draft_revision": 2,
|
||||
"revises_source_request_id": "req-mode-preview",
|
||||
}
|
||||
)
|
||||
|
||||
result, events = _stream(
|
||||
assistant,
|
||||
"把小憩模式的空调温度调整到25度",
|
||||
execute_control=lambda instruction, _progress: (
|
||||
calls.append(instruction) or result_payload
|
||||
),
|
||||
mode_profile_draft_context={
|
||||
"name": "小憩模式",
|
||||
"source_request_id": "req-mode-preview",
|
||||
"vehicle_id": "denza_n7",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["route"] == "vehicle_control"
|
||||
assert result["success"] is True
|
||||
assert calls == ["把小憩模式的空调温度调整到25度"]
|
||||
assert client.turn_calls == []
|
||||
assert events[0]["engine"] == "n7web-mode-builder"
|
||||
assert "调整待保存" in result["answer"]
|
||||
|
||||
|
||||
def test_active_draft_context_does_not_hijack_an_ordinary_question() -> None:
|
||||
client = FakeCodexClient()
|
||||
assistant = CodexAssistantService(client)
|
||||
|
||||
result, events = _stream(
|
||||
assistant,
|
||||
"解释一下空调内循环和外循环的区别",
|
||||
mode_profile_draft_context={
|
||||
"name": "小憩模式",
|
||||
"source_request_id": "req-mode-preview",
|
||||
"vehicle_id": "denza_n7",
|
||||
},
|
||||
)
|
||||
|
||||
assert result == {"route": "agent", "answer": "这是 Codex 的流式回答。"}
|
||||
assert client.turn_calls[0]["text"] == "解释一下空调内循环和外循环的区别"
|
||||
assert events[0]["engine"] == "codex-app-server"
|
||||
|
||||
|
||||
def test_malformed_stale_draft_context_is_ignored_for_an_ordinary_question() -> None:
|
||||
client = FakeCodexClient()
|
||||
assistant = CodexAssistantService(client)
|
||||
events: list[dict[str, object]] = []
|
||||
|
||||
result = assistant.stream(
|
||||
"解释一下空调内循环和外循环的区别",
|
||||
conversation_id="conv-stale-draft-context",
|
||||
principal="alice",
|
||||
history=[],
|
||||
emit=lambda event: events.append(event),
|
||||
execute_control=lambda _instruction, _progress: pytest.fail(
|
||||
"vehicle control was unexpected"
|
||||
),
|
||||
control_terms=CONTROL_TERMS,
|
||||
mode_profile_draft_context={"untrusted": "stale"},
|
||||
)
|
||||
|
||||
assert result["route"] == "agent"
|
||||
assert events[0]["engine"] == "codex-app-server"
|
||||
|
||||
|
||||
def test_malformed_matching_draft_reference_is_rejected_before_control() -> None:
|
||||
assistant = CodexAssistantService(FakeCodexClient(available=False))
|
||||
|
||||
with pytest.raises(ValueError, match="fields are invalid"):
|
||||
assistant.stream(
|
||||
"把小憩模式的空调温度调整到25度",
|
||||
conversation_id="conv-invalid-draft-context",
|
||||
principal="alice",
|
||||
history=[],
|
||||
emit=lambda _event: True,
|
||||
execute_control=lambda _instruction, _progress: pytest.fail(
|
||||
"vehicle control was unexpected"
|
||||
),
|
||||
control_terms=CONTROL_TERMS,
|
||||
mode_profile_draft_context={"name": "小憩模式"},
|
||||
)
|
||||
|
||||
|
||||
def test_nonmatching_draft_context_does_not_claim_another_mode_update() -> None:
|
||||
authorization = _authorize_vehicle_request(
|
||||
"把观影模式的空调温度调整到25度",
|
||||
CONTROL_TERMS,
|
||||
{
|
||||
"name": "小憩模式",
|
||||
"source_request_id": "req-mode-preview",
|
||||
"vehicle_id": "denza_n7",
|
||||
},
|
||||
)
|
||||
|
||||
# The stale card reference has been ignored. The sentence is still an
|
||||
# explicit registered control under the legacy saved-mode update route.
|
||||
assert authorization.reason == "explicit_registered_control"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"message",
|
||||
(
|
||||
"把小憩模式的空调介绍一下",
|
||||
"小憩模式的空调有什么作用",
|
||||
"小憩模式的空调和普通空调有什么区别",
|
||||
"小憩模式的空调设置有哪些?",
|
||||
"小憩模式的空调该调几度",
|
||||
"把小憩模式的空调调到25度合适吗",
|
||||
"调整小憩模式的空调参数,列出来",
|
||||
),
|
||||
)
|
||||
def test_device_word_air_conditioner_does_not_fake_a_draft_update_verb(
|
||||
message: str,
|
||||
) -> None:
|
||||
authorization = _authorize_vehicle_request(
|
||||
message,
|
||||
CONTROL_TERMS,
|
||||
{
|
||||
"name": "小憩模式",
|
||||
"source_request_id": "req-mode-preview",
|
||||
"vehicle_id": "denza_n7",
|
||||
},
|
||||
)
|
||||
|
||||
assert authorization.reason != "explicit_mode_profile_draft_update"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"message",
|
||||
(
|
||||
"修改小憩模式的空调设置是什么",
|
||||
"小憩模式的空调该调几度",
|
||||
"把小憩模式的空调调到25度合适吗",
|
||||
"调整小憩模式的空调参数,列出来",
|
||||
"小憩模式的空调调到25度会怎样",
|
||||
),
|
||||
)
|
||||
def test_mode_profile_question_with_control_words_never_calls_vehicle_executor(
|
||||
message: str,
|
||||
) -> None:
|
||||
assistant = CodexAssistantService(FakeCodexClient())
|
||||
|
||||
result, events = _stream(
|
||||
assistant,
|
||||
message,
|
||||
execute_control=lambda _instruction, _progress: pytest.fail(
|
||||
"mode-profile question reached vehicle executor"
|
||||
),
|
||||
mode_profile_draft_context={
|
||||
"name": "小憩模式",
|
||||
"source_request_id": "req-mode-preview",
|
||||
"vehicle_id": "denza_n7",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["route"] == "agent"
|
||||
assert events[0]["engine"] == "codex-app-server"
|
||||
|
||||
|
||||
def test_unknown_name_only_mode_uses_codex_and_overrides_model_execution_claim() -> None:
|
||||
client = FakeCodexClient()
|
||||
assistant = CodexAssistantService(client)
|
||||
|
||||
@@ -36,10 +36,10 @@ 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-22") < html.index(
|
||||
assert html.index("/assistant-chat.js?v=codex-assistant-23") < html.index(
|
||||
"/energy-monitor.js?v=energy-monitor-4"
|
||||
)
|
||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v271"' in service_worker
|
||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v272"' in service_worker
|
||||
|
||||
|
||||
def test_energy_monitor_uses_the_fifth_shared_dock_icon_and_starts_closed() -> None:
|
||||
|
||||
@@ -48,7 +48,7 @@ def test_theme_asset_versions_are_consistent_with_the_pwa_shell() -> None:
|
||||
):
|
||||
assert asset in html
|
||||
assert f'"{asset}"' in service_worker
|
||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v271"' in service_worker
|
||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v272"' in service_worker
|
||||
|
||||
|
||||
def test_vehicle_twin_icons_and_nested_overlays_use_semantic_tokens() -> None:
|
||||
|
||||
Reference in New Issue
Block a user