merge: integrate remote MQTT routing with seat-scoped dynamic modes
This commit is contained in:
@@ -34,7 +34,7 @@ async function main() {
|
||||
});
|
||||
async function handleApiRoute(route) {
|
||||
const pathname = new URL(route.request().url()).pathname;
|
||||
if (pathname === '/api/hardware/seat-state') {
|
||||
if (['/api/hardware/seat-state', '/api/hardware/vehicle-state'].includes(pathname)) {
|
||||
// This legacy GET may attempt automatic firmware recovery. Never touch
|
||||
// the physical gateway while verifying browser interactions.
|
||||
await route.fulfill({json:{status:'unavailable',hardware_execution_allowed:false}});
|
||||
|
||||
@@ -1101,7 +1101,7 @@ class TestCodexAssistantWebApi:
|
||||
assert "not-for-mcp" not in serialized_events
|
||||
assert "模拟编排" in events[-1]["answer"]
|
||||
|
||||
def test_structured_plan_crosses_the_internal_bridge_without_replacing_original_text(
|
||||
def test_explicit_roof_control_uses_local_preview_without_codex(
|
||||
self,
|
||||
) -> None:
|
||||
events = self.post_assistant(
|
||||
@@ -1112,17 +1112,14 @@ class TestCodexAssistantWebApi:
|
||||
}
|
||||
)
|
||||
|
||||
assert FAKE_CODEX.bridge_results[-1][0] == 200
|
||||
call = AssistantApiHandler.service.calls[-1]
|
||||
assert call["utterance"] == "天幕透光率调到30%"
|
||||
assert call["provider"] == "local"
|
||||
forwarded_plan = call["assistant_plan"]
|
||||
assert forwarded_plan["requests"][0]["capability"] == "roof.set_transmittance"
|
||||
assert forwarded_plan["requests"][0]["parameters"] == [
|
||||
{"name": "transmittance_pct", "value": 30}
|
||||
]
|
||||
assert forwarded_plan["requests"][0]["reason"] == "原始用户请求的结构化候选动作。"
|
||||
assert "instruction" not in call
|
||||
assert "assistant_plan" not in call
|
||||
assert not FAKE_CODEX.turn_calls
|
||||
assert not FAKE_CODEX.bridge_results
|
||||
assert events[0]["route"] == "vehicle_control"
|
||||
assert events[0]["engine"] == "n7web-control-router"
|
||||
assert events[-1]["type"] == "answer.completed"
|
||||
|
||||
def test_assistant_returns_named_mode_draft_without_saving_it(self) -> None:
|
||||
|
||||
@@ -10,6 +10,7 @@ from http import HTTPStatus
|
||||
from http.server import ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -52,7 +53,13 @@ def test_mode_rail_real_browser_interactions(tmp_path: Path) -> None:
|
||||
class BrowserHandler(SeatDemoHandler):
|
||||
auth = AuthRuntime(AuthConfig(False, "", "", b"", 3600, 5, 300, 120, False))
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
def do_GET(self) -> None:
|
||||
if urlparse(self.path).path in {"/api/hardware/seat-state", "/api/hardware/vehicle-state"}:
|
||||
self._json({"status": "unavailable", "hardware_execution_allowed": False})
|
||||
return
|
||||
super().do_GET()
|
||||
|
||||
def do_POST(self) -> None:
|
||||
# Only GET reaches this test server. Browser requests to the
|
||||
# assistant are intercepted; unexpected writes fail closed.
|
||||
self._json({"error": "browser_test_write_forbidden"}, HTTPStatus.FORBIDDEN)
|
||||
|
||||
+248
-20
@@ -13,6 +13,7 @@ from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from cognitive_agent.application.codex_assistant import CodexAssistantService
|
||||
from cognitive_agent.application.mode_profile import ModeProfileRegistry
|
||||
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
|
||||
from cognitive_agent.application.self_extension import (
|
||||
@@ -48,6 +49,7 @@ class SeatWebApiTests(unittest.TestCase):
|
||||
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false",
|
||||
"COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED": "false",
|
||||
"COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED": "false",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
@@ -134,6 +136,66 @@ class SeatWebApiTests(unittest.TestCase):
|
||||
unauthenticated.open(f"{self.base_url}/api/runtime", timeout=3)
|
||||
self.assertEqual(caught.exception.code, 401)
|
||||
|
||||
def test_n7_mode_scene_catalog_is_public_and_read_only(self) -> None:
|
||||
unauthenticated = urllib.request.build_opener()
|
||||
|
||||
with unauthenticated.open(f"{self.base_url}/n7/scenes", timeout=3) as response:
|
||||
catalog = json.loads(response.read().decode("utf-8"))
|
||||
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(response.headers["Cache-Control"], "no-store")
|
||||
self.assertEqual(response.headers["Access-Control-Allow-Origin"], "*")
|
||||
self.assertEqual(catalog["service"], "n7-scenes")
|
||||
self.assertEqual(catalog["schema_version"], 1)
|
||||
self.assertEqual(catalog["vehicle_id"], "denza_n7")
|
||||
self.assertGreaterEqual(catalog["scene_count"], 13)
|
||||
self.assertEqual(catalog["scene_count"], len(catalog["scenes"]))
|
||||
office = next(scene for scene in catalog["scenes"] if scene["id"] == "office")
|
||||
self.assertIn(office["origin"], {"preset", "customized"})
|
||||
self.assertTrue(office["actions"])
|
||||
self.assertIn("capability", office["actions"][0])
|
||||
self.assertNotIn("command_flow", json.dumps(catalog, ensure_ascii=False))
|
||||
|
||||
preflight = urllib.request.Request(
|
||||
f"{self.base_url}/n7/scenes",
|
||||
method="OPTIONS",
|
||||
)
|
||||
with unauthenticated.open(preflight, timeout=3) as response:
|
||||
self.assertEqual(response.status, 204)
|
||||
self.assertEqual(response.headers["Access-Control-Allow-Origin"], "*")
|
||||
self.assertIn("GET", response.headers["Access-Control-Allow-Methods"])
|
||||
|
||||
def test_schedule_status_stays_authenticated_and_sanitized_beside_public_catalog(self) -> None:
|
||||
endpoint = f"{self.base_url}/api/modes/schedule/status?vehicle_id=denza_n7"
|
||||
unauthenticated = urllib.request.build_opener()
|
||||
with self.assertRaises(urllib.error.HTTPError) as caught:
|
||||
unauthenticated.open(endpoint, timeout=3)
|
||||
self.assertEqual(caught.exception.code, 401)
|
||||
state = {
|
||||
"status": "running", "vehicle_id": "denza_n7", "session_id": "conv-mode-schedule-api",
|
||||
"run_id": "schedule-private-safe-id", "phase_index": 0, "repeat_index": 0,
|
||||
"phase_count": 2, "repeat_count": 2, "executed_phases": 0,
|
||||
"elapsed_ms": 250, "total_duration_ms": 6000, "completed": False,
|
||||
"phase_results": [{"raw_can": "private-command-bytes"}],
|
||||
"hardware_client_id": "private-bench-client",
|
||||
"error": "private transport stack or secret",
|
||||
}
|
||||
with (
|
||||
patch.object(self.service.mode_schedule_runner, "status", return_value=state) as status,
|
||||
self.opener.open(endpoint, timeout=3) as response,
|
||||
):
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
status.assert_called_once_with("denza_n7")
|
||||
self.assertEqual(payload["status"], "running")
|
||||
self.assertEqual(payload["phase_count"], 2)
|
||||
self.assertEqual(payload["total_duration_ms"], 6000)
|
||||
self.assertEqual(payload["completed"], False)
|
||||
self.assertIn("后续阶段已停止", payload["error"])
|
||||
self.assertNotIn("phase_results", payload)
|
||||
self.assertNotIn("hardware_client_id", payload)
|
||||
self.assertNotIn("private-command-bytes", json.dumps(payload))
|
||||
self.assertNotIn("private transport", json.dumps(payload))
|
||||
|
||||
def test_user_mode_delete_endpoint_removes_only_user_profiles(self) -> None:
|
||||
original_registry = self.service.mode_profiles
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
@@ -409,6 +471,12 @@ operations:
|
||||
}
|
||||
)
|
||||
try:
|
||||
with self.opener.open(
|
||||
f"{self.base_url}/api/vehicle-display-sync", timeout=3
|
||||
) as response:
|
||||
display_before_compile = json.loads(
|
||||
response.read().decode("utf-8")
|
||||
)
|
||||
generate_request = urllib.request.Request(
|
||||
f"{self.base_url}/api/requests/preview",
|
||||
method="POST",
|
||||
@@ -428,6 +496,16 @@ operations:
|
||||
)
|
||||
with self.opener.open(generate_request, timeout=3) as response:
|
||||
generated = json.loads(response.read().decode("utf-8"))
|
||||
with self.opener.open(
|
||||
f"{self.base_url}/api/vehicle-display-sync", timeout=3
|
||||
) as response:
|
||||
display_after_compile = json.loads(
|
||||
response.read().decode("utf-8")
|
||||
)
|
||||
self.assertEqual(
|
||||
display_after_compile["task"],
|
||||
display_before_compile["task"],
|
||||
)
|
||||
|
||||
save_request = urllib.request.Request(
|
||||
f"{self.base_url}/api/mode-profiles/office",
|
||||
@@ -621,18 +699,18 @@ operations:
|
||||
def test_static_console_and_runtime_endpoint(self) -> None:
|
||||
with self.opener.open(f"{self.base_url}/", timeout=3) as response:
|
||||
html = response.read().decode("utf-8")
|
||||
with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-132", timeout=3) as response:
|
||||
with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-133", timeout=3) as response:
|
||||
app_script = response.read().decode("utf-8")
|
||||
with self.opener.open(
|
||||
f"{self.base_url}/gesture-control.js?v=android-pad-9", timeout=3
|
||||
) as response:
|
||||
gesture_script = response.read().decode("utf-8")
|
||||
with self.opener.open(
|
||||
f"{self.base_url}/console-v2.js?v=delivery-state-62", timeout=3
|
||||
f"{self.base_url}/console-v2.js?v=local-first-66", timeout=3
|
||||
) as response:
|
||||
console_script = response.read().decode("utf-8")
|
||||
with self.opener.open(
|
||||
f"{self.base_url}/console-v2.css?v=delivery-state-66", timeout=3
|
||||
f"{self.base_url}/console-v2.css?v=delivery-state-68", timeout=3
|
||||
) as response:
|
||||
console_styles = response.read().decode("utf-8")
|
||||
with self.opener.open(
|
||||
@@ -656,7 +734,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-28", timeout=3
|
||||
f"{self.base_url}/assistant-chat.js?v=codex-assistant-29", timeout=3
|
||||
) as response:
|
||||
assistant_script = response.read().decode("utf-8")
|
||||
with self.opener.open(
|
||||
@@ -697,12 +775,12 @@ operations:
|
||||
self.assertIn("CMD 0x431 · FB 0x41A · CH1", html)
|
||||
self.assertIn("CMD 0x431 · FB 0x388 · CH1", html)
|
||||
self.assertNotIn("透明车体 · 实体设备", html)
|
||||
self.assertIn('src="/app.js?v=dual-seat-132"', html)
|
||||
self.assertIn('src="/app.js?v=dual-seat-133"', html)
|
||||
self.assertIn('href="/styles.css?v=dual-seat-23"', html)
|
||||
self.assertIn('src="/gesture-control.js?v=android-pad-9"', html)
|
||||
self.assertNotIn('class="vehicle-system-hud"', html)
|
||||
self.assertIn('src="/console-v2.js?v=delivery-state-62"', html)
|
||||
self.assertIn('href="/console-v2.css?v=delivery-state-66"', html)
|
||||
self.assertIn('src="/console-v2.js?v=local-first-66"', html)
|
||||
self.assertIn('href="/console-v2.css?v=delivery-state-68"', html)
|
||||
self.assertIn('src="/speech-input.js?v=sensevoice-13"', html)
|
||||
self.assertIn('href="/speech-input.css?v=sensevoice-7"', html)
|
||||
self.assertIn('id="speechDictationButton"', html)
|
||||
@@ -718,8 +796,8 @@ operations:
|
||||
self.assertNotIn("滚动查看更多", html)
|
||||
self.assertIn('aria-label="发送消息"', html)
|
||||
self.assertIn('id="assistantTranscript"', html)
|
||||
self.assertIn('href="/assistant-chat.css?v=codex-assistant-28"', html)
|
||||
self.assertIn('src="/assistant-chat.js?v=codex-assistant-28"', html)
|
||||
self.assertIn('href="/assistant-chat.css?v=codex-assistant-29"', html)
|
||||
self.assertIn('src="/assistant-chat.js?v=codex-assistant-29"', 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)
|
||||
@@ -902,7 +980,12 @@ operations:
|
||||
self.assertNotIn('data-mode-command="seat driver vent low"', app_script)
|
||||
self.assertIn("CH0 副驾座椅与 CH1 主驾座椅独立收发、独立反馈", app_script)
|
||||
self.assertIn("CH1 主驾实车测试不启用无闭环的座椅位置手势", app_script)
|
||||
self.assertIn("车端 CAN 未启用,其他领域保持虚拟执行", app_script)
|
||||
self.assertNotIn("车端 CAN 未启用,其他领域保持虚拟执行", app_script)
|
||||
self.assertNotIn('id="hardwareNote"', html)
|
||||
self.assertNotIn('id="hardwareAliasNote"', html)
|
||||
self.assertNotIn('id="vehicleTransportNote"', html)
|
||||
self.assertIn("下发控制指令", console_script)
|
||||
self.assertIn('new Set(["mode_editor_compile"])', console_script)
|
||||
self.assertIn("function extractReferenceWindowPanes", app_script)
|
||||
self.assertIn("function collectReferenceFrontLampMeshes", app_script)
|
||||
self.assertIn("function emphasizeReferenceWindowPanes", app_script)
|
||||
@@ -992,14 +1075,14 @@ 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-v279"', service_worker)
|
||||
self.assertIn('/console-v2.css?v=delivery-state-66', service_worker)
|
||||
self.assertIn('/console-v2.js?v=delivery-state-62', service_worker)
|
||||
self.assertIn('/assistant-chat.css?v=codex-assistant-28', service_worker)
|
||||
self.assertIn('/assistant-chat.js?v=codex-assistant-28', service_worker)
|
||||
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v283"', service_worker)
|
||||
self.assertIn('/console-v2.css?v=delivery-state-68', service_worker)
|
||||
self.assertIn('/console-v2.js?v=local-first-66', service_worker)
|
||||
self.assertIn('/assistant-chat.css?v=codex-assistant-29', service_worker)
|
||||
self.assertIn('/assistant-chat.js?v=codex-assistant-29', 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-132"', service_worker)
|
||||
self.assertIn('"/app.js?v=dual-seat-133"', service_worker)
|
||||
self.assertIn('"/speech-input.js?v=sensevoice-13"', service_worker)
|
||||
self.assertIn('"/speech-input.css?v=sensevoice-7"', service_worker)
|
||||
self.assertIn('"/speech-audio-worklet.js?v=sensevoice-5"', service_worker)
|
||||
@@ -1456,7 +1539,7 @@ operations:
|
||||
) as response:
|
||||
extension_script = response.read().decode("utf-8")
|
||||
with self.opener.open(
|
||||
f"{self.base_url}/app.js?v=dual-seat-132", timeout=3
|
||||
f"{self.base_url}/app.js?v=dual-seat-133", timeout=3
|
||||
) as response:
|
||||
app_script = response.read().decode("utf-8")
|
||||
|
||||
@@ -2105,7 +2188,7 @@ operations:
|
||||
},
|
||||
data=json.dumps(
|
||||
{
|
||||
"utterance": "打开闪烁灯,颜色切换到红色,打开空调",
|
||||
"utterance": "空调温度设置到23度,打开闪烁灯",
|
||||
"provider": "local",
|
||||
"conversation_id": "conv-mixed-nfc-can",
|
||||
"execution_mode": "dry_run",
|
||||
@@ -2131,23 +2214,138 @@ operations:
|
||||
self.assertEqual(synced["learned_count"], 2)
|
||||
self.assertEqual(events[-1]["type"], "result")
|
||||
self.assertTrue(any(item["type"] == "validated_commands" for item in events))
|
||||
self.assertLess(
|
||||
next(
|
||||
index
|
||||
for index, item in enumerate(events)
|
||||
if item["type"] == "validated_commands"
|
||||
),
|
||||
len(events) - 1,
|
||||
)
|
||||
validated_payloads = [
|
||||
item["payload"]
|
||||
for item in events
|
||||
if item["type"] == "validated_commands"
|
||||
]
|
||||
self.assertTrue(validated_payloads)
|
||||
# The local-first stage publishes known IR controls before CAN parsing;
|
||||
# subsequent evidence must include the complete combined command set.
|
||||
self.assertEqual(set(validated_payloads[0]["function_configs"]), {"self_extension"})
|
||||
self.assertTrue(
|
||||
all(
|
||||
"self_extension" in payload["function_configs"]
|
||||
for payload in validated_payloads
|
||||
)
|
||||
)
|
||||
first_validated = next(
|
||||
payload for payload in validated_payloads
|
||||
if {"native_climate", "self_extension"}.issubset(payload["function_configs"])
|
||||
)
|
||||
self.assertTrue(
|
||||
{"native_climate", "self_extension"}.issubset(
|
||||
first_validated["command_plans"]
|
||||
)
|
||||
)
|
||||
validated_operations = [
|
||||
operation
|
||||
for configuration in first_validated["function_configs"].values()
|
||||
for operation in configuration["spec"].get("operations", [])
|
||||
]
|
||||
self.assertEqual(
|
||||
{operation["capability"] for operation in validated_operations},
|
||||
{"climate.set_temperature", "infrared.emit"},
|
||||
)
|
||||
self.assertTrue(
|
||||
all(
|
||||
operation.get("parse_source") == "local_rule"
|
||||
for operation in validated_operations
|
||||
)
|
||||
)
|
||||
validated_commands = [
|
||||
command
|
||||
for plan in first_validated["command_plans"].values()
|
||||
for command in plan["spec"].get("commands", [])
|
||||
]
|
||||
climate_command = next(
|
||||
command
|
||||
for command in validated_commands
|
||||
if command.get("signals", {}).get("debug_command")
|
||||
== "ac temp driver 23"
|
||||
)
|
||||
self.assertEqual(climate_command["signals"]["frame_id"], "0x1DE")
|
||||
self.assertEqual(
|
||||
climate_command["signals"]["data_hex"],
|
||||
"00 00 00 00 17 00 00 00",
|
||||
)
|
||||
infrared_command = next(
|
||||
command
|
||||
for command in validated_commands
|
||||
if command.get("message") == "infrared.emit"
|
||||
)
|
||||
self.assertEqual(infrared_command["signals"]["slot"], 1)
|
||||
assistant_projection = CodexAssistantService._validated_control_payload(
|
||||
first_validated
|
||||
)
|
||||
self.assertIsNotNone(assistant_projection)
|
||||
projected_commands = assistant_projection["validated_control_commands"]
|
||||
projected_by_capability = {
|
||||
item["operation"]["capability"]: item for item in projected_commands
|
||||
}
|
||||
self.assertEqual(
|
||||
projected_by_capability["climate.set_temperature"]["actual_command"],
|
||||
"ac temp driver 23",
|
||||
)
|
||||
self.assertEqual(
|
||||
projected_by_capability["climate.set_temperature"]["frames"][0][
|
||||
"value"
|
||||
],
|
||||
"0x1DE · 00 00 00 00 17 00 00 00",
|
||||
)
|
||||
self.assertEqual(
|
||||
projected_by_capability["infrared.emit"]["actual_command"],
|
||||
"infrared emit 1",
|
||||
)
|
||||
serialized_projection = json.dumps(
|
||||
assistant_projection, ensure_ascii=False
|
||||
)
|
||||
self.assertNotIn("command_plans", serialized_projection)
|
||||
self.assertNotIn("data_hex", serialized_projection)
|
||||
compile_trace = next(
|
||||
event
|
||||
for event in first_validated["trace_events"]
|
||||
if event["stage"] == "compile"
|
||||
)
|
||||
validated_traces = first_validated["trace_events"][
|
||||
: first_validated["trace_events"].index(compile_trace) + 1
|
||||
]
|
||||
required_validation_stages = {
|
||||
"intent_parse",
|
||||
"context_resolve",
|
||||
"domain_route",
|
||||
"function_generate",
|
||||
"schema_validate",
|
||||
"compile",
|
||||
}
|
||||
actual_validation_stages = {
|
||||
event["stage"] for event in validated_traces
|
||||
}
|
||||
self.assertTrue(
|
||||
required_validation_stages.issubset(actual_validation_stages),
|
||||
sorted(actual_validation_stages),
|
||||
)
|
||||
self.assertTrue(
|
||||
all(
|
||||
isinstance(event.get("duration_ms"), int)
|
||||
and event["duration_ms"] >= 0
|
||||
for event in validated_traces
|
||||
)
|
||||
)
|
||||
self.assertEqual(result["status"], "succeeded")
|
||||
self.assertEqual(result["orchestration_route"], "unified_can_infrared")
|
||||
self.assertEqual(
|
||||
[item["slot"] for item in result["infrared_actions"]], [1, 3]
|
||||
result["provider_used"], "local_rules+infrared_local_rules"
|
||||
)
|
||||
self.assertEqual([item["slot"] for item in result["infrared_actions"]], [1])
|
||||
self.assertEqual(
|
||||
result["infrared_actions"][0]["parse_source"], "local_rule"
|
||||
)
|
||||
@@ -2288,10 +2486,15 @@ operations:
|
||||
|
||||
self.assertEqual(events[-1]["type"], "error")
|
||||
self.assertEqual(events[-1]["error"], "can_transport_failed")
|
||||
validated = next(
|
||||
validated_payloads = [
|
||||
item["payload"]
|
||||
for item in events
|
||||
if item["type"] == "validated_commands"
|
||||
]
|
||||
self.assertEqual(set(validated_payloads[0]["function_configs"]), {"self_extension"})
|
||||
validated = next(
|
||||
payload for payload in validated_payloads
|
||||
if {"native_climate", "self_extension"}.issubset(payload["function_configs"])
|
||||
)
|
||||
self.assertEqual(validated["status"], "processing")
|
||||
self.assertEqual(
|
||||
@@ -2665,6 +2868,31 @@ operations:
|
||||
planner.assert_called_once()
|
||||
self.assertEqual(planner.call_args.args[0]["utterance"], utterance)
|
||||
|
||||
def test_peripheral_timing_aliases_preserve_sequence_before_local_action_execution(self) -> None:
|
||||
handler = object.__new__(SeatDemoHandler)
|
||||
handler.service = SimpleNamespace(is_mode_profile_configuration_request=lambda _text: False)
|
||||
capabilities = [
|
||||
{"id": identity, "status": "learned", "device_id": "third-party-light",
|
||||
"device_name": "第三方氛围灯", "card_uid": "1A53DD95", "action": action, "slot": slot}
|
||||
for identity, action, slot in (("on", "开启灯光", 1), ("red", "红色灯光", 2), ("off", "关闭灯光", 3))
|
||||
]
|
||||
handler.extension = SimpleNamespace(list_peripheral_capabilities=lambda: capabilities)
|
||||
handler.nfc = SimpleNamespace(snapshot=lambda: {
|
||||
"reader": {"online": True}, "card": {"action": "present", "uid": "1A53DD95"},
|
||||
})
|
||||
for duration in ("5s", "5S", "500ms", "500MS", "1.5s", "5秒", "半分钟"):
|
||||
with (
|
||||
self.subTest(duration=duration),
|
||||
patch.object(handler, "_execute_active_peripheral_request", return_value={"status": "succeeded"}) as planner,
|
||||
patch.object(handler, "_execute_local_peripheral_actions", side_effect=AssertionError("sequence flattened")),
|
||||
):
|
||||
result = handler._execute_request_with_peripherals("/api/requests", {
|
||||
"utterance": f"打开第三方氛围灯,红灯闪烁{duration}后,最后关闭第三方氛围灯",
|
||||
"provider": "local", "execution_mode": "dry_run",
|
||||
})
|
||||
self.assertEqual(result["status"], "succeeded")
|
||||
planner.assert_called_once()
|
||||
|
||||
def test_post_request_returns_complete_dry_run(self) -> None:
|
||||
request = urllib.request.Request(
|
||||
f"{self.base_url}/api/requests",
|
||||
|
||||
@@ -287,6 +287,59 @@ test("a completed NDJSON answer remains the authoritative visible response", asy
|
||||
assert.equal(harness.completed[0].error, null);
|
||||
});
|
||||
|
||||
test("assistant bridges request start and validated local controls to the console", async () => {
|
||||
const payload = {
|
||||
request_id: "req-local-mixed",
|
||||
utterance: "空调温度设置到23度,打开闪烁灯",
|
||||
execution_mode: "dry_run",
|
||||
provider_used: "local_rules+infrared_local_rules",
|
||||
validated_control_commands: [
|
||||
{
|
||||
operation: {
|
||||
id: "climate-temperature-1",
|
||||
capability: "climate.set_temperature",
|
||||
target: { zone: "driver" },
|
||||
parameters: { temperature_c: 23 },
|
||||
parse_source: "local_rule",
|
||||
},
|
||||
channel: "CAN1",
|
||||
actual_command: "ac temp driver 23",
|
||||
frames: [{
|
||||
label: "CAN1 帧",
|
||||
value: "0x1DE · 00 00 00 00 17 00 00 00",
|
||||
}],
|
||||
},
|
||||
],
|
||||
trace_events: [{ stage: "compile", duration_ms: 2 }],
|
||||
};
|
||||
const harness = createHarness([
|
||||
{ type: "validated_control_commands", payload },
|
||||
{ type: "answer.completed", route: "vehicle_control", answer: "模拟编排已完成。" },
|
||||
]);
|
||||
const starts = [];
|
||||
const validated = [];
|
||||
harness.sandbox.addEventListener("cognitive-agent:control-request-start", (event) => {
|
||||
starts.push(event.detail);
|
||||
});
|
||||
harness.sandbox.addEventListener("cognitive-agent:validated-can-commands", (event) => {
|
||||
validated.push(event.detail);
|
||||
});
|
||||
harness.input.value = payload.utterance;
|
||||
harness.input.dataset.speechUtteranceId = "speech-local-mixed";
|
||||
harness.input.dataset.speechValue = payload.utterance;
|
||||
|
||||
await harness.sandbox.CognitiveAgentAssistant.submit();
|
||||
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(starts)), [{
|
||||
utterance: payload.utterance,
|
||||
speech_utterance_id: "speech-local-mixed",
|
||||
speech_value: payload.utterance,
|
||||
}]);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(validated)), [payload]);
|
||||
assert.equal(harness.requestCalls.length, 1);
|
||||
assert.equal(JSON.parse(harness.requestCalls[0].options.body).execution_mode, "dry_run");
|
||||
});
|
||||
|
||||
test("unsupported-control suggestions render once and click through as a new command", async () => {
|
||||
const suggestions = [
|
||||
{ id: "climate-on", label: "打开空调", command: "打开空调", domain: "climate" },
|
||||
|
||||
@@ -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-28"' in html
|
||||
assert 'src="/assistant-chat.js?v=codex-assistant-28"' in html
|
||||
assert 'href="/assistant-chat.css?v=codex-assistant-29"' in html
|
||||
assert 'src="/assistant-chat.js?v=codex-assistant-29"' 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-v279"' in service_worker
|
||||
assert '"/assistant-chat.css?v=codex-assistant-28"' in service_worker
|
||||
assert '"/assistant-chat.js?v=codex-assistant-28"' in service_worker
|
||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v283"' in service_worker
|
||||
assert '"/assistant-chat.css?v=codex-assistant-29"' in service_worker
|
||||
assert '"/assistant-chat.js?v=codex-assistant-29"' in service_worker
|
||||
|
||||
|
||||
def test_codex_frontend_uses_one_streaming_endpoint_and_agent_work_events():
|
||||
@@ -33,6 +33,11 @@ def test_codex_frontend_uses_one_streaming_endpoint_and_agent_work_events():
|
||||
assert 'event.type === "answer.delta"' in script
|
||||
assert '"work.started", "work.progress"' in script
|
||||
assert 'event.type === "work.completed"' in script
|
||||
assert 'event.type === "validated_control_commands"' in script
|
||||
assert '"cognitive-agent:validated-can-commands"' in script
|
||||
assert '"cognitive-agent:control-request-start"' in script
|
||||
assert "speech_utterance_id:" in script
|
||||
assert "speech_value:" in script
|
||||
assert 'Object.prototype.hasOwnProperty.call(event, "answer")' in script
|
||||
assert 'pendingController.abort("user_stop")' in script
|
||||
assert 'signal.addEventListener("abort", cancelReader, { once: true })' in script
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import unittest
|
||||
from copy import deepcopy
|
||||
|
||||
from cognitive_agent.application.codex_assistant import (
|
||||
CodexAssistantService,
|
||||
_VehicleAuthorization,
|
||||
_VehicleTurnContext,
|
||||
)
|
||||
|
||||
|
||||
UTTERANCE = "空调温度设置到23度,打开闪烁灯"
|
||||
|
||||
|
||||
def compiled_mixed_result() -> dict[str, object]:
|
||||
return {
|
||||
"request_id": "req-assistant-local-mixed",
|
||||
"utterance": UTTERANCE,
|
||||
"status": "processing",
|
||||
"phase": "commands_validated",
|
||||
"provider_used": "local_rules+infrared_local_rules",
|
||||
"execution_mode": "dry_run",
|
||||
"control_transport": "wired_can",
|
||||
"function_configs": {
|
||||
"native_climate": {
|
||||
"spec": {
|
||||
"domain": "climate",
|
||||
"operations": [
|
||||
{
|
||||
"id": "climate-temperature-1",
|
||||
"capability": "climate.set_temperature",
|
||||
"target": {"zone": "driver"},
|
||||
"parameters": {"temperature_c": 23},
|
||||
"parse_source": "local_rule",
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
"self_extension": {
|
||||
"spec": {
|
||||
"domain": "self_extension",
|
||||
"operations": [
|
||||
{
|
||||
"id": "infrared-1",
|
||||
"capability": "infrared.emit",
|
||||
"target": {"device": "toy-001", "name": "闪灯玩具"},
|
||||
"parameters": {"action": "开启", "slot": 1},
|
||||
"parse_source": "local_rule",
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
},
|
||||
"command_plans": {
|
||||
"self_extension": {
|
||||
"spec": {
|
||||
"domain": "self_extension",
|
||||
"commands": [
|
||||
{
|
||||
"operation_ref": "unrelated-operation",
|
||||
"message": "Native_0xBAD",
|
||||
"signals": {
|
||||
"debug_command": "must-not-cross-tool-boundary",
|
||||
"frame_id": "0xBAD",
|
||||
"data_hex": "DE AD BE EF",
|
||||
},
|
||||
},
|
||||
{
|
||||
"operation_ref": "infrared-1",
|
||||
"bus": "TCP",
|
||||
"message": "infrared.emit",
|
||||
"signals": {"slot": 1},
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
"native_climate": {
|
||||
"spec": {
|
||||
"domain": "climate",
|
||||
"commands": [
|
||||
{
|
||||
"operation_ref": "climate-temperature-1",
|
||||
"bus": "body_can",
|
||||
"message": "Native_0x1DE",
|
||||
"signals": {
|
||||
"debug_command": "ac temp driver 23",
|
||||
"frame_id": "0x1DE",
|
||||
"data_hex": "00 00 00 00 17 00 00 00",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
},
|
||||
"trace_events": [
|
||||
{"stage": "intent_parse", "duration_ms": 2},
|
||||
{"stage": "context_resolve", "duration_ms": 1},
|
||||
{"stage": "domain_route", "duration_ms": 1},
|
||||
{"stage": "function_generate", "duration_ms": 2},
|
||||
{"stage": "schema_validate", "duration_ms": 1},
|
||||
{"stage": "compile", "duration_ms": 2},
|
||||
{"stage": "execute", "duration_ms": 999},
|
||||
],
|
||||
"raw_can": "must-not-cross-tool-boundary",
|
||||
}
|
||||
|
||||
|
||||
class ValidatedControlProjectionTest(unittest.TestCase):
|
||||
def test_projection_exposes_only_exact_display_commands(self) -> None:
|
||||
projection = CodexAssistantService._validated_control_payload(
|
||||
compiled_mixed_result()
|
||||
)
|
||||
|
||||
self.assertIsNotNone(projection)
|
||||
assert projection is not None
|
||||
commands = projection["validated_control_commands"]
|
||||
assert isinstance(commands, list)
|
||||
self.assertEqual(len(commands), 2)
|
||||
by_capability = {
|
||||
command["operation"]["capability"]: command for command in commands
|
||||
}
|
||||
climate = by_capability["climate.set_temperature"]
|
||||
infrared = by_capability["infrared.emit"]
|
||||
self.assertEqual(climate["actual_command"], "ac temp driver 23")
|
||||
self.assertEqual(
|
||||
climate["frames"],
|
||||
[
|
||||
{
|
||||
"label": "CAN1 帧",
|
||||
"value": "0x1DE · 00 00 00 00 17 00 00 00",
|
||||
}
|
||||
],
|
||||
)
|
||||
self.assertEqual(infrared["actual_command"], "infrared emit 1")
|
||||
self.assertEqual(
|
||||
infrared["frames"],
|
||||
[
|
||||
{
|
||||
"label": "GSCU1B TCP",
|
||||
"value": '{"action":"emit","data":{"no":1},"type":"infrared"}',
|
||||
}
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
[event["stage"] for event in projection["trace_events"]],
|
||||
[
|
||||
"intent_parse",
|
||||
"context_resolve",
|
||||
"domain_route",
|
||||
"function_generate",
|
||||
"schema_validate",
|
||||
"compile",
|
||||
],
|
||||
)
|
||||
serialized = json.dumps(projection, ensure_ascii=False)
|
||||
self.assertNotIn("command_plans", serialized)
|
||||
self.assertNotIn("data_hex", serialized)
|
||||
self.assertNotIn("raw_can", serialized)
|
||||
self.assertNotIn("must-not-cross-tool-boundary", serialized)
|
||||
|
||||
def test_projection_drops_secrets_and_sensitive_nested_keys(self) -> None:
|
||||
result = compiled_mixed_result()
|
||||
secret = "projection-secret-token"
|
||||
climate_config = result["function_configs"]["native_climate"]
|
||||
climate_config["spec"]["domain"] = f"climate-{secret}"
|
||||
operation = climate_config["spec"]["operations"][0]
|
||||
operation["target"].update(
|
||||
{
|
||||
"api_key": "API-LEAK",
|
||||
"cookie": "COOKIE-LEAK",
|
||||
}
|
||||
)
|
||||
operation["parameters"].update(
|
||||
{
|
||||
"credential": "CRED-LEAK",
|
||||
"access_key": "ACCESS-LEAK",
|
||||
}
|
||||
)
|
||||
operation["parse_source"] = f"local-{secret}"
|
||||
result["command_plans"]["native_climate"]["spec"]["commands"][0][
|
||||
"signals"
|
||||
]["debug_command"] = f"ac temp driver 23 {secret}"
|
||||
|
||||
projection = CodexAssistantService._validated_control_payload(
|
||||
result,
|
||||
secret=secret,
|
||||
)
|
||||
|
||||
self.assertIsNotNone(projection)
|
||||
assert projection is not None
|
||||
serialized = json.dumps(projection, ensure_ascii=False)
|
||||
for forbidden in (
|
||||
secret,
|
||||
"API-LEAK",
|
||||
"COOKIE-LEAK",
|
||||
"CRED-LEAK",
|
||||
"ACCESS-LEAK",
|
||||
"api_key",
|
||||
"credential",
|
||||
"access_key",
|
||||
):
|
||||
self.assertNotIn(forbidden, serialized)
|
||||
climate = next(
|
||||
item
|
||||
for item in projection["validated_control_commands"]
|
||||
if item["operation"]["capability"] == "climate.set_temperature"
|
||||
)
|
||||
self.assertEqual(climate["domain"], "")
|
||||
self.assertNotIn("parse_source", climate["operation"])
|
||||
self.assertEqual(climate["operation"]["target"], {"zone": "driver"})
|
||||
self.assertEqual(
|
||||
climate["operation"]["parameters"],
|
||||
{"temperature_c": 23},
|
||||
)
|
||||
self.assertEqual(climate["actual_command"], "")
|
||||
|
||||
def test_projection_drops_operation_when_identity_contains_secret(self) -> None:
|
||||
result = compiled_mixed_result()
|
||||
secret = "projection-secret-token"
|
||||
operation = result["function_configs"]["native_climate"]["spec"][
|
||||
"operations"
|
||||
][0]
|
||||
operation["id"] = f"climate-{secret}"
|
||||
result["command_plans"]["native_climate"]["spec"]["commands"][0][
|
||||
"operation_ref"
|
||||
] = operation["id"]
|
||||
|
||||
projection = CodexAssistantService._validated_control_payload(
|
||||
result,
|
||||
secret=secret,
|
||||
)
|
||||
|
||||
self.assertIsNotNone(projection)
|
||||
assert projection is not None
|
||||
serialized = json.dumps(projection, ensure_ascii=False)
|
||||
self.assertNotIn(secret, serialized)
|
||||
self.assertEqual(
|
||||
[
|
||||
item["operation"]["capability"]
|
||||
for item in projection["validated_control_commands"]
|
||||
],
|
||||
["infrared.emit"],
|
||||
)
|
||||
|
||||
def test_duplicate_operation_refs_never_cross_configuration_boundaries(self) -> None:
|
||||
result = {
|
||||
"function_configs": {
|
||||
"native_climate": {
|
||||
"spec": {
|
||||
"domain": "climate",
|
||||
"operations": [
|
||||
{
|
||||
"id": "op-1",
|
||||
"capability": "climate.set_temperature",
|
||||
"target": {"zone": "driver"},
|
||||
"parameters": {"temperature_c": 23},
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
"native_seat": {
|
||||
"spec": {
|
||||
"domain": "seat",
|
||||
"operations": [
|
||||
{
|
||||
"id": "op-1",
|
||||
"capability": "seat.set_heating",
|
||||
"target": {"zone": "passenger"},
|
||||
"parameters": {"level": 2},
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
},
|
||||
"command_plans": {
|
||||
"native_seat": {
|
||||
"spec": {
|
||||
"domain": "seat",
|
||||
"commands": [
|
||||
{
|
||||
"operation_ref": "op-1",
|
||||
"bus": "seat_can0",
|
||||
"signals": {
|
||||
"debug_command": "seat passenger heat 2"
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
projection = CodexAssistantService._validated_control_payload(result)
|
||||
|
||||
self.assertIsNotNone(projection)
|
||||
assert projection is not None
|
||||
commands = projection["validated_control_commands"]
|
||||
self.assertEqual(len(commands), 1)
|
||||
self.assertEqual(
|
||||
commands[0]["operation"]["capability"],
|
||||
"seat.set_heating",
|
||||
)
|
||||
self.assertEqual(commands[0]["actual_command"], "seat passenger heat 2")
|
||||
|
||||
def test_compiled_semantic_operation_is_kept_without_debug_command(self) -> None:
|
||||
result = deepcopy(compiled_mixed_result())
|
||||
climate_command = result["command_plans"]["native_climate"]["spec"][
|
||||
"commands"
|
||||
][0]
|
||||
climate_command["signals"].pop("debug_command")
|
||||
|
||||
projection = CodexAssistantService._validated_control_payload(result)
|
||||
|
||||
self.assertIsNotNone(projection)
|
||||
assert projection is not None
|
||||
climate = next(
|
||||
item
|
||||
for item in projection["validated_control_commands"]
|
||||
if item["operation"]["capability"] == "climate.set_temperature"
|
||||
)
|
||||
self.assertEqual(climate["actual_command"], "")
|
||||
self.assertEqual(
|
||||
climate["frames"][0]["value"],
|
||||
"0x1DE · 00 00 00 00 17 00 00 00",
|
||||
)
|
||||
|
||||
def test_direct_stream_emits_projection_before_progress_and_completion(self) -> None:
|
||||
client = type("UnavailableClient", (), {"available": False})()
|
||||
assistant = CodexAssistantService(client)
|
||||
events: list[dict[str, object]] = []
|
||||
|
||||
def execute_control(instruction, progress):
|
||||
self.assertEqual(instruction, UTTERANCE)
|
||||
result = compiled_mixed_result()
|
||||
progress(result)
|
||||
return {**result, "status": "succeeded"}
|
||||
|
||||
result = assistant.stream(
|
||||
UTTERANCE,
|
||||
conversation_id="conv-validated-projection",
|
||||
principal="local-test",
|
||||
history=[],
|
||||
emit=events.append,
|
||||
execute_control=execute_control,
|
||||
control_terms=("空调", "闪烁灯"),
|
||||
quick_control_commands=[UTTERANCE],
|
||||
)
|
||||
|
||||
event_types = [event["type"] for event in events]
|
||||
validated_index = event_types.index("validated_control_commands")
|
||||
self.assertLess(validated_index, event_types.index("action.progress"))
|
||||
self.assertLess(validated_index, event_types.index("action.completed"))
|
||||
self.assertLess(validated_index, event_types.index("answer.completed"))
|
||||
self.assertEqual(result["route"], "vehicle_control")
|
||||
self.assertEqual(result["success"], True)
|
||||
|
||||
safe_result = CodexAssistantService._safe_vehicle_result(
|
||||
compiled_mixed_result()
|
||||
)
|
||||
self.assertNotIn("command_plans", safe_result)
|
||||
self.assertNotIn("validated_control_commands", safe_result)
|
||||
|
||||
def test_agent_tool_path_queues_projection_for_the_browser_thread(self) -> None:
|
||||
client = type("UnavailableClient", (), {"available": False})()
|
||||
assistant = CodexAssistantService(client)
|
||||
request_token = "projection-secret-token"
|
||||
|
||||
def execute_control(instruction, progress):
|
||||
self.assertEqual(instruction, UTTERANCE)
|
||||
result = compiled_mixed_result()
|
||||
progress(result)
|
||||
return {**result, "status": "succeeded"}
|
||||
|
||||
context = _VehicleTurnContext(
|
||||
original_message=UTTERANCE,
|
||||
execute_control=execute_control,
|
||||
authorization=_VehicleAuthorization(
|
||||
instruction=UTTERANCE,
|
||||
reason="explicit_registered_control",
|
||||
),
|
||||
capability_snapshot={},
|
||||
query_capabilities=lambda _snapshot, _query: {},
|
||||
lock=threading.Lock(),
|
||||
)
|
||||
assistant._tool_contexts[request_token] = context
|
||||
|
||||
safe_result = assistant.execute_vehicle_control(request_token, UTTERANCE)
|
||||
|
||||
queued_events = []
|
||||
while not context.events.empty():
|
||||
queued_events.append(context.events.get_nowait())
|
||||
event_types = [event["type"] for event in queued_events]
|
||||
self.assertEqual(event_types[0], "action.started")
|
||||
self.assertLess(
|
||||
event_types.index("validated_control_commands"),
|
||||
event_types.index("action.progress"),
|
||||
)
|
||||
self.assertLess(
|
||||
event_types.index("validated_control_commands"),
|
||||
event_types.index("action.completed"),
|
||||
)
|
||||
self.assertNotIn("command_plans", safe_result)
|
||||
self.assertNotIn(request_token, json.dumps(queued_events, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -395,6 +395,8 @@ def _stream(
|
||||
history: list[dict[str, str]] | None = None,
|
||||
emit: Callable[[dict[str, object]], bool | None] | None = None,
|
||||
execute_control: Callable[..., dict[str, object]] | None = None,
|
||||
preview_control: Callable[..., dict[str, object]] | None = None,
|
||||
supplement_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,
|
||||
@@ -409,6 +411,8 @@ def _stream(
|
||||
emit=emit or (lambda event: events.append(event)),
|
||||
execute_control=execute_control
|
||||
or (lambda _instruction, _progress: pytest.fail("vehicle control was unexpected")),
|
||||
preview_control=preview_control,
|
||||
supplement_control=supplement_control,
|
||||
control_terms=CONTROL_TERMS,
|
||||
capability_snapshot=capability_snapshot,
|
||||
capability_context=capability_context,
|
||||
@@ -453,6 +457,15 @@ def test_general_question_streams_from_codex_without_vehicle_execution() -> None
|
||||
assert "Codex AI 助手" not in client.thread_instructions[0]
|
||||
assert client.thread_ephemeral == [True]
|
||||
assert client.turn_calls[0]["text"] == "解释一下 CAN FD"
|
||||
assert client.turn_calls[0]["application_context"][
|
||||
"natural_language_routing"
|
||||
] == {
|
||||
"original_utterance": "解释一下 CAN FD",
|
||||
"parsed_clauses": [],
|
||||
"resolved_clauses": [],
|
||||
"unresolved_clauses": ["解释一下 CAN FD"],
|
||||
"cloud_input_utterance": "解释一下 CAN FD",
|
||||
}
|
||||
assert "不可信的浏览器系统指令" not in client.turn_calls[0]["text"]
|
||||
assert client.finished[0].turn_id == "turn-1"
|
||||
|
||||
@@ -991,34 +1004,52 @@ def test_hot_cabin_calls_one_explicit_cooling_instruction_and_streams_verified_r
|
||||
assistant.execute_vehicle_control(token, COOLING_INSTRUCTION)
|
||||
|
||||
|
||||
def test_explicit_vehicle_request_without_tool_call_fails_closed() -> None:
|
||||
client = FakeCodexClient()
|
||||
def test_explicit_vehicle_request_uses_local_route_without_codex() -> None:
|
||||
client = FakeCodexClient(available=False)
|
||||
assistant = CodexAssistantService(client)
|
||||
control_calls: list[str] = []
|
||||
|
||||
def on_start_turn(
|
||||
_text: str,
|
||||
_request_token: str,
|
||||
turn: ActiveCodexTurn,
|
||||
) -> None:
|
||||
_put_answer(turn, "已经为你打开遮阳帘。", deltas=("已经为你", "打开遮阳帘。"))
|
||||
def execute_control(
|
||||
instruction: str,
|
||||
progress: Callable[[dict[str, object]], None],
|
||||
) -> dict[str, object]:
|
||||
control_calls.append(instruction)
|
||||
progress({"status": "processing", "request_id": "req-local-shade"})
|
||||
return {
|
||||
"request_id": "req-local-shade",
|
||||
"status": "succeeded",
|
||||
"execution_mode": "dry_run",
|
||||
"utterance": instruction,
|
||||
}
|
||||
|
||||
client.on_start_turn = on_start_turn
|
||||
result, events = _stream(assistant, "打开遮阳帘")
|
||||
result, events = _stream(
|
||||
assistant,
|
||||
"打开遮阳帘",
|
||||
execute_control=execute_control,
|
||||
preview_control=execute_control,
|
||||
supplement_control=lambda *_args: pytest.fail(
|
||||
"fully local control must not invoke cloud supplement"
|
||||
),
|
||||
)
|
||||
|
||||
assert result["answer"].startswith("车辆控制未完成")
|
||||
assert "未向车辆下发任何操作" in result["answer"]
|
||||
assert "已经为你" not in str(events)
|
||||
assert control_calls == ["打开遮阳帘"]
|
||||
assert result["route"] == "vehicle_control"
|
||||
assert result["success"] is True
|
||||
assert "打开遮阳帘" in result["answer"]
|
||||
assert [event["type"] for event in events] == [
|
||||
"message.accepted",
|
||||
"work.started",
|
||||
"action.started",
|
||||
"action.progress",
|
||||
"action.completed",
|
||||
"answer.delta",
|
||||
"work.completed",
|
||||
"answer.completed",
|
||||
]
|
||||
assert events[2]["success"] is False
|
||||
assert events[4]["success"] is False
|
||||
assert events[-1]["answer"] == result["answer"]
|
||||
assert events[0]["engine"] == "n7web-control-router"
|
||||
assert events[1]["display_text"] == "正在本地解析并下发明确控制指令……"
|
||||
assert not client.thread_instructions
|
||||
assert not client.turn_calls
|
||||
|
||||
|
||||
def test_exact_server_owned_quick_control_executes_without_codex() -> None:
|
||||
@@ -1057,22 +1088,253 @@ def test_exact_server_owned_quick_control_executes_without_codex() -> None:
|
||||
assert not client.turn_calls
|
||||
|
||||
|
||||
def test_quick_control_requires_exact_normalized_command_match() -> None:
|
||||
client = FakeCodexClient()
|
||||
def test_explicit_control_does_not_require_exact_quick_card_match() -> None:
|
||||
client = FakeCodexClient(available=False)
|
||||
assistant = CodexAssistantService(client)
|
||||
control_calls: list[str] = []
|
||||
|
||||
def execute_control(
|
||||
instruction: str,
|
||||
_progress: Callable[[dict[str, object]], None],
|
||||
) -> dict[str, object]:
|
||||
control_calls.append(instruction)
|
||||
return {
|
||||
"request_id": "req-explicit-local",
|
||||
"status": "succeeded",
|
||||
"execution_mode": "dry_run",
|
||||
"utterance": instruction,
|
||||
}
|
||||
|
||||
result, events = _stream(
|
||||
assistant,
|
||||
"请关闭所有车窗",
|
||||
execute_control=lambda instruction, _progress: control_calls.append(instruction),
|
||||
execute_control=execute_control,
|
||||
preview_control=execute_control,
|
||||
supplement_control=lambda *_args: pytest.fail(
|
||||
"fully local control must not invoke cloud supplement"
|
||||
),
|
||||
quick_control_commands=["关闭所有车窗"],
|
||||
)
|
||||
|
||||
assert events[0]["route"] == "agent"
|
||||
assert result["answer"].startswith("车辆控制未完成")
|
||||
assert not control_calls
|
||||
assert client.turn_calls
|
||||
assert events[0]["route"] == "vehicle_control"
|
||||
assert result["success"] is True
|
||||
assert control_calls == ["请关闭所有车窗"]
|
||||
assert not client.turn_calls
|
||||
|
||||
|
||||
def test_mixed_control_streams_local_result_before_unresolved_codex_turn() -> None:
|
||||
client = FakeCodexClient()
|
||||
assistant = CodexAssistantService(client)
|
||||
order: list[str] = []
|
||||
original = "空调温度设置为23度并把遮阳帘调整到适合观影"
|
||||
local_clause = "空调温度设置为23度"
|
||||
unresolved_clause = "把遮阳帘调整到适合观影"
|
||||
preview_result = {
|
||||
"request_id": "req-local-preview",
|
||||
"status": "partially_succeeded",
|
||||
"execution_mode": "dry_run",
|
||||
"utterance": original,
|
||||
"provider_used": "local_rules",
|
||||
"local_control_budget_ms": 1000,
|
||||
"local_control_elapsed_ms": 18.0,
|
||||
"local_control_within_budget": True,
|
||||
"natural_language_routing": {
|
||||
"original_utterance": original,
|
||||
"parsed_clauses": [local_clause],
|
||||
"resolved_clauses": [local_clause],
|
||||
"unresolved_clauses": [unresolved_clause],
|
||||
},
|
||||
"function_configs": {
|
||||
"climate": {
|
||||
"spec": {
|
||||
"domain": "climate",
|
||||
"operations": [
|
||||
{
|
||||
"id": "local-temperature",
|
||||
"capability": "climate.set_temperature",
|
||||
"target": {"zone": "driver"},
|
||||
"parameters": {"temperature_c": 23},
|
||||
"parse_source": "local_rule",
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
},
|
||||
"command_plans": {
|
||||
"climate": {
|
||||
"spec": {
|
||||
"domain": "climate",
|
||||
"commands": [
|
||||
{
|
||||
"operation_ref": "local-temperature",
|
||||
"bus": "body-can1",
|
||||
"message": "climate.set_temperature",
|
||||
"signals": {"debug_command": "ac temp driver 23"},
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
def preview_control(
|
||||
instruction: str,
|
||||
progress: Callable[[dict[str, object]], None],
|
||||
) -> dict[str, object]:
|
||||
assert instruction == original
|
||||
order.append("local_preview")
|
||||
progress(preview_result)
|
||||
return preview_result
|
||||
|
||||
def supplement_control(
|
||||
instruction: str,
|
||||
progress: Callable[[dict[str, object]], None],
|
||||
plan: dict[str, object],
|
||||
) -> dict[str, object]:
|
||||
assert instruction == unresolved_clause
|
||||
assert plan["requests"][0]["capability"] == "roof.set_shade_position"
|
||||
order.append("cloud_supplement")
|
||||
result = {
|
||||
"request_id": "req-cloud-supplement",
|
||||
"status": "succeeded",
|
||||
"execution_mode": "dry_run",
|
||||
"utterance": original,
|
||||
}
|
||||
progress(result)
|
||||
return result
|
||||
|
||||
cloud_plan = {
|
||||
"detected_domains": ["roof"],
|
||||
"requests": [
|
||||
{
|
||||
"domain": "roof",
|
||||
"capability": "roof.set_shade_position",
|
||||
"target_scope": "vehicle",
|
||||
"operation": "set",
|
||||
"duration_ms": None,
|
||||
"parameters": [{"name": "position_pct", "value": 100}],
|
||||
"reason": "补足未解析的观影遮光需求。",
|
||||
}
|
||||
],
|
||||
"ambiguities": [],
|
||||
"decision_summary": ["只处理未解析的遮阳帘子句。"],
|
||||
"confidence": 0.9,
|
||||
}
|
||||
|
||||
def on_start_turn(
|
||||
text: str,
|
||||
request_token: str,
|
||||
turn: ActiveCodexTurn,
|
||||
) -> None:
|
||||
order.append("cloud_turn")
|
||||
assert text == unresolved_clause
|
||||
assistant.execute_cockpit_request(request_token, cloud_plan)
|
||||
_put_answer(turn, "本地温度已先行处理,遮阳帘补充控制已完成。")
|
||||
|
||||
client.on_start_turn = on_start_turn
|
||||
result, events = _stream(
|
||||
assistant,
|
||||
original,
|
||||
preview_control=preview_control,
|
||||
supplement_control=supplement_control,
|
||||
)
|
||||
|
||||
assert order == ["local_preview", "cloud_turn", "cloud_supplement"]
|
||||
assert client.turn_calls[0]["text"] == unresolved_clause
|
||||
assert client.turn_calls[0]["application_context"][
|
||||
"natural_language_routing"
|
||||
] == {
|
||||
"original_utterance": original,
|
||||
"parsed_clauses": [local_clause],
|
||||
"resolved_clauses": [local_clause],
|
||||
"unresolved_clauses": [unresolved_clause],
|
||||
"cloud_input_utterance": unresolved_clause,
|
||||
}
|
||||
validated_index = next(
|
||||
index
|
||||
for index, event in enumerate(events)
|
||||
if event["type"] == "validated_control_commands"
|
||||
)
|
||||
cloud_progress_index = next(
|
||||
index
|
||||
for index, event in enumerate(events)
|
||||
if event.get("display_text")
|
||||
== "本地明确子句已处理,正在补充未解析子句……"
|
||||
)
|
||||
assert validated_index < cloud_progress_index
|
||||
assert result["route"] == "agent"
|
||||
|
||||
|
||||
def test_fully_unresolved_control_starts_bounded_cloud_turn_and_fails_closed() -> None:
|
||||
client = FakeCodexClient()
|
||||
assistant = CodexAssistantService(client)
|
||||
instruction = "把遮阳帘调整到适合观影"
|
||||
preview_result = {
|
||||
"request_id": "req-unresolved-preview",
|
||||
"preview_request_id": "req-unresolved-preview",
|
||||
"status": "no_action",
|
||||
"execution_mode": "dry_run",
|
||||
"utterance": instruction,
|
||||
"provider_used": "local_rules",
|
||||
"function_configs": {},
|
||||
"command_plans": {},
|
||||
"local_control_budget_ms": 1000,
|
||||
"local_control_elapsed_ms": 4.0,
|
||||
"local_control_within_budget": True,
|
||||
"supplement_required": True,
|
||||
"natural_language_routing": {
|
||||
"original_utterance": instruction,
|
||||
"parsed_clauses": [],
|
||||
"resolved_clauses": [],
|
||||
"unresolved_clauses": [instruction],
|
||||
},
|
||||
}
|
||||
|
||||
def preview_control(
|
||||
text: str,
|
||||
_progress: Callable[[dict[str, object]], None],
|
||||
) -> dict[str, object]:
|
||||
assert text == instruction
|
||||
return preview_result
|
||||
|
||||
def on_start_turn(
|
||||
text: str,
|
||||
_request_token: str,
|
||||
turn: ActiveCodexTurn,
|
||||
) -> None:
|
||||
assert text == instruction
|
||||
# The browser must never receive this unsupported execution claim
|
||||
# because no guarded vehicle tool call follows it.
|
||||
_put_answer(turn, "遮阳帘已经调整完成。")
|
||||
|
||||
client.on_start_turn = on_start_turn
|
||||
result, events = _stream(
|
||||
assistant,
|
||||
instruction,
|
||||
preview_control=preview_control,
|
||||
supplement_control=lambda *_args: pytest.fail(
|
||||
"a model answer without a tool call must not execute control"
|
||||
),
|
||||
)
|
||||
|
||||
assert client.turn_calls[0]["text"] == instruction
|
||||
assert client.turn_calls[0]["application_context"][
|
||||
"natural_language_routing"
|
||||
] == {
|
||||
"original_utterance": instruction,
|
||||
"parsed_clauses": [],
|
||||
"resolved_clauses": [],
|
||||
"unresolved_clauses": [instruction],
|
||||
"cloud_input_utterance": instruction,
|
||||
}
|
||||
assert not any(
|
||||
event["type"] == "validated_control_commands" for event in events
|
||||
)
|
||||
assert result == {
|
||||
"route": "agent",
|
||||
"answer": "车辆控制未完成:AI 助手没有调用受控执行链,未向车辆下发任何操作。",
|
||||
}
|
||||
assert events[-1]["answer"] == result["answer"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -35,10 +35,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-28") < html.index(
|
||||
assert html.index("/assistant-chat.js?v=codex-assistant-29") < html.index(
|
||||
"/energy-monitor.js?v=energy-monitor-4"
|
||||
)
|
||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v279"' in service_worker
|
||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v283"' in service_worker
|
||||
|
||||
|
||||
def test_energy_monitor_uses_the_fifth_shared_dock_icon_and_starts_closed() -> None:
|
||||
@@ -160,7 +160,7 @@ def test_energy_monitor_tracks_ai_and_direct_control_activity() -> None:
|
||||
assert "completeControlActivity" in script
|
||||
assert 'event.type === "action.progress" && event.payload' in assistant
|
||||
assert 'new CustomEvent("cognitive-agent:vehicle-control-activity"' in assistant
|
||||
assert 'new CustomEvent("cognitive-agent:validated-can-commands"' not in assistant
|
||||
assert '"cognitive-agent:validated-can-commands"' in assistant
|
||||
|
||||
|
||||
def test_energy_monitor_reads_persistent_seat_state_for_both_seats() -> None:
|
||||
|
||||
@@ -41,13 +41,13 @@ def test_theme_asset_versions_are_consistent_with_the_pwa_shell() -> None:
|
||||
|
||||
for asset in (
|
||||
"/styles.css?v=dual-seat-23",
|
||||
"/console-v2.css?v=delivery-state-66",
|
||||
"/console-v2.css?v=delivery-state-68",
|
||||
"/self-extension.css?v=gscu-tcp-43",
|
||||
"/speech-input.css?v=sensevoice-7",
|
||||
):
|
||||
assert asset in html
|
||||
assert f'"{asset}"' in service_worker
|
||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v279"' in service_worker
|
||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v283"' in service_worker
|
||||
|
||||
|
||||
def test_vehicle_twin_icons_and_nested_overlays_use_semantic_tokens() -> None:
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from cognitive_agent.application.mode_profile import ModeProfileRegistry
|
||||
from cognitive_agent.application.mode_scene_catalog import (
|
||||
ModeSceneCatalogPublisher,
|
||||
build_mode_scene_catalog,
|
||||
)
|
||||
from cognitive_agent.application.mode_schedule import ModeSchedule
|
||||
from cognitive_agent.application.seat_service import REPOSITORY_ROOT
|
||||
|
||||
|
||||
def _write_office_preset(path: Path) -> None:
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text(
|
||||
"""
|
||||
id: office
|
||||
name: 办公模式
|
||||
description: N7 办公场景。
|
||||
icon: briefcase
|
||||
scope:
|
||||
vehicle_ids: [denza_n7]
|
||||
operations:
|
||||
- domain: climate
|
||||
capability: climate.set_temperature
|
||||
target_scope: passenger
|
||||
operation: set
|
||||
parameters:
|
||||
temperature_c: 24
|
||||
""".strip()
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def test_publisher_tracks_create_override_and_delete(tmp_path: Path) -> None:
|
||||
preset_root = tmp_path / "presets"
|
||||
user_root = tmp_path / "user"
|
||||
_write_office_preset(
|
||||
preset_root / "vehicles" / "denza_n7" / "preset" / "office.yaml"
|
||||
)
|
||||
registry = ModeProfileRegistry(preset_root, user_root=user_root)
|
||||
catalog_path = tmp_path / "scenses" / "demo-scenes.json"
|
||||
publisher = ModeSceneCatalogPublisher(
|
||||
catalog_path,
|
||||
"denza_n7",
|
||||
server_id="test-n7",
|
||||
)
|
||||
registry.add_change_listener(publisher.publish)
|
||||
|
||||
initial = json.loads(catalog_path.read_text(encoding="utf-8"))
|
||||
assert initial["service"] == "n7-scenes"
|
||||
assert initial["schema_version"] == 1
|
||||
assert initial["server_id"] == "test-n7"
|
||||
assert initial["scene_count"] == 1
|
||||
assert initial["scenes"][0]["origin"] == "preset"
|
||||
assert initial["scenes"][0]["actions"] == [
|
||||
{
|
||||
"type": "climate_temperature",
|
||||
"at_ms": 0,
|
||||
"domain": "climate",
|
||||
"capability": "climate.set_temperature",
|
||||
"target_scope": "passenger",
|
||||
"operation": "set",
|
||||
"parameters": {"temperature_c": 24},
|
||||
"zone": "passenger",
|
||||
"celsius": 24,
|
||||
}
|
||||
]
|
||||
|
||||
created = registry.save_mode(
|
||||
"阅读模式",
|
||||
[
|
||||
{
|
||||
"domain": "ambient_light",
|
||||
"capability": "ambient_light.set_illumination",
|
||||
"target_scope": "vehicle",
|
||||
"parameters": {"color_hex": "#E8F3FF", "brightness_pct": 40},
|
||||
}
|
||||
],
|
||||
vehicle_id="denza_n7",
|
||||
)
|
||||
after_create = json.loads(catalog_path.read_text(encoding="utf-8"))
|
||||
created_scene = next(
|
||||
scene for scene in after_create["scenes"] if scene["id"] == created.id
|
||||
)
|
||||
assert after_create["scene_count"] == 2
|
||||
assert created_scene["origin"] == "user"
|
||||
assert created_scene["deletable"] is True
|
||||
assert created_scene["actions"][0]["parameters"]["brightness_pct"] == 40
|
||||
|
||||
fan_mode = registry.save_mode(
|
||||
"微风模式",
|
||||
[
|
||||
{
|
||||
"domain": "climate",
|
||||
"capability": "climate.set_fan_level",
|
||||
"target_scope": "vehicle",
|
||||
"parameters": {"level": 2},
|
||||
}
|
||||
],
|
||||
vehicle_id="denza_n7",
|
||||
)
|
||||
after_fan_create = json.loads(catalog_path.read_text(encoding="utf-8"))
|
||||
fan_scene = next(
|
||||
scene for scene in after_fan_create["scenes"] if scene["id"] == fan_mode.id
|
||||
)
|
||||
assert fan_scene["actions"][0]["type"] == "climate_fan"
|
||||
assert fan_scene["actions"][0]["level"] == 2
|
||||
|
||||
registry.save_mode(
|
||||
"专注办公模式",
|
||||
[
|
||||
{
|
||||
"domain": "climate",
|
||||
"capability": "climate.set_temperature",
|
||||
"target_scope": "passenger",
|
||||
"parameters": {"temperature_c": 26},
|
||||
}
|
||||
],
|
||||
mode_id="office",
|
||||
vehicle_id="denza_n7",
|
||||
)
|
||||
after_override = json.loads(catalog_path.read_text(encoding="utf-8"))
|
||||
office = next(scene for scene in after_override["scenes"] if scene["id"] == "office")
|
||||
assert office["label"] == "专注办公模式"
|
||||
assert office["origin"] == "customized"
|
||||
assert office["preset_override"] is True
|
||||
assert office["actions"][0]["celsius"] == 26
|
||||
|
||||
registry.delete_user_mode(created.id, "denza_n7")
|
||||
registry.delete_user_mode(fan_mode.id, "denza_n7")
|
||||
registry.delete_user_mode("office", "denza_n7")
|
||||
after_delete = json.loads(catalog_path.read_text(encoding="utf-8"))
|
||||
assert [scene["id"] for scene in after_delete["scenes"]] == ["office"]
|
||||
assert after_delete["scenes"][0]["label"] == "办公模式"
|
||||
assert after_delete["scenes"][0]["origin"] == "preset"
|
||||
|
||||
|
||||
def test_repository_presets_are_exported_without_internal_command_flows() -> None:
|
||||
registry = ModeProfileRegistry(REPOSITORY_ROOT / "config" / "modes")
|
||||
|
||||
catalog = build_mode_scene_catalog(
|
||||
registry,
|
||||
"denza_n7",
|
||||
generated_at="2026-09-09T00:00:00Z",
|
||||
)
|
||||
|
||||
assert catalog["scene_count"] >= 13
|
||||
assert len({scene["id"] for scene in catalog["scenes"]}) == catalog["scene_count"]
|
||||
by_id = {scene["id"]: scene for scene in catalog["scenes"]}
|
||||
assert by_id["office"]["operation_count"] == len(registry.get_profile("office", "denza_n7").operations)
|
||||
assert by_id["rocking_horse"]["behavior"] == "cyclic"
|
||||
assert by_id["rocking_horse"]["timeline"]["repeat"] == "until_stopped_or_timeout"
|
||||
encoded = json.dumps(catalog, ensure_ascii=False)
|
||||
assert "command_flow" not in encoded
|
||||
assert "source_request_id" not in encoded
|
||||
|
||||
|
||||
def _schedule() -> dict:
|
||||
return ModeSchedule.from_dict({
|
||||
"schema": "cognitive-agent-mode-schedule/v1",
|
||||
"repeat_count": 2,
|
||||
"phases": [
|
||||
{"id": "warm", "name": "暖舱", "duration_ms": 1000, "operations": [
|
||||
{"domain": "climate", "capability": "climate.set_temperature", "target_scope": "vehicle",
|
||||
"operation": "set", "parameters": {"temperature_c": 28}},
|
||||
]},
|
||||
{"id": "settle", "name": "舒适", "duration_ms": 2000, "operations": [
|
||||
{"domain": "climate", "capability": "climate.set_temperature", "target_scope": "vehicle",
|
||||
"operation": "set", "parameters": {"temperature_c": 24}},
|
||||
{"domain": "seat", "capability": "seat.set_backrest_recline", "target_scope": "passenger",
|
||||
"operation": "set", "parameters": {"position_pct": 45}},
|
||||
]},
|
||||
],
|
||||
}).to_dict()
|
||||
|
||||
|
||||
def test_schedule_catalog_preserves_phases_but_never_exports_flat_executable_actions(tmp_path: Path) -> None:
|
||||
registry = ModeProfileRegistry(tmp_path / "presets", user_root=tmp_path / "user")
|
||||
schedule = _schedule()
|
||||
profile = registry.save_mode(
|
||||
"分阶段舒适模式", schedule["phases"][0]["operations"], vehicle_id="denza_n7",
|
||||
schedule=ModeSchedule.from_dict(schedule),
|
||||
command_flow={"source_request_id": "req-private", "raw_can": "private-transport-bytes"},
|
||||
)
|
||||
catalog = build_mode_scene_catalog(registry, "denza_n7")
|
||||
scene = next(item for item in catalog["scenes"] if item["id"] == profile.id)
|
||||
assert scene["schedule"] == schedule
|
||||
assert scene["behavior"] == "cyclic"
|
||||
assert scene["operation_count"] == 3
|
||||
assert scene["domains"] == ["climate", "seat"]
|
||||
assert scene["actions"] == [], "legacy clients must not execute all timeline stages at once"
|
||||
assert scene["execution"] == {
|
||||
"kind": "server_schedule", "mode_id": profile.id,
|
||||
"client_actions_supported": False, "requires_authentication": True,
|
||||
}
|
||||
assert "服务端时间轴" in scene["execution_notice"]
|
||||
assert scene["preview_actions_scope"] == "single_cycle"
|
||||
preview = scene["preview_actions"]
|
||||
assert [action["phase_id"] for action in preview] == ["warm", "settle", "settle"]
|
||||
assert [action["at_ms"] for action in preview] == [0, 1000, 1000]
|
||||
assert [action["phase_duration_ms"] for action in preview] == [1000, 2000, 2000]
|
||||
assert [action["celsius"] for action in preview if action["type"] == "climate_temperature"] == [28, 24]
|
||||
assert preview[-1]["target_scope"] == "passenger"
|
||||
assert preview[-1]["parameters"] == {"position_pct": 45}
|
||||
assert ModeSchedule.from_dict(scene["schedule"]).total_duration_ms == 6000
|
||||
encoded = json.dumps(catalog)
|
||||
assert "source_request_id" not in encoded
|
||||
assert "private-transport-bytes" not in encoded
|
||||
assert "command_flow" not in encoded
|
||||
|
||||
|
||||
def test_schedule_publisher_updates_after_save_reload_duration_change_and_delete(tmp_path: Path) -> None:
|
||||
registry = ModeProfileRegistry(tmp_path / "presets", user_root=tmp_path / "user")
|
||||
publisher = ModeSceneCatalogPublisher(tmp_path / "catalog.json", "denza_n7")
|
||||
registry.add_change_listener(publisher.publish)
|
||||
assert publisher.snapshot()["scene_count"] == 0
|
||||
schedule = _schedule()
|
||||
created = registry.save_mode(
|
||||
"动态释压模式", schedule["phases"][0]["operations"], vehicle_id="denza_n7",
|
||||
schedule=ModeSchedule.from_dict(schedule),
|
||||
)
|
||||
first = publisher.snapshot()
|
||||
assert first["scenes"][0]["schedule"] == schedule
|
||||
registry.reload()
|
||||
assert publisher.snapshot() == first
|
||||
schedule["phases"][1]["duration_ms"] = 5000
|
||||
registry.save_mode(
|
||||
created.name, schedule["phases"][0]["operations"], vehicle_id="denza_n7",
|
||||
mode_id=created.id, schedule=ModeSchedule.from_dict(schedule),
|
||||
)
|
||||
changed = publisher.snapshot()
|
||||
assert changed["catalog_revision"] != first["catalog_revision"]
|
||||
assert changed["scenes"][0]["profile_revision"] != first["scenes"][0]["profile_revision"]
|
||||
assert changed["scenes"][0]["schedule"]["phases"][1]["duration_ms"] == 5000
|
||||
assert changed["scenes"][0]["actions"] == []
|
||||
assert json.loads(publisher.path.read_text(encoding="utf-8")) == changed
|
||||
registry.delete_user_mode(created.id, "denza_n7")
|
||||
assert publisher.snapshot()["scene_count"] == 0
|
||||
assert publisher.snapshot()["scenes"] == []
|
||||
@@ -42,10 +42,22 @@ class _FakeMqttClient:
|
||||
self.on_message = None
|
||||
self.subscriptions: list[tuple[str, int]] = []
|
||||
self.published: list[tuple[str, str, int, bool]] = []
|
||||
self.websocket_options: list[dict[str, object]] = []
|
||||
self.credentials: tuple[str, str | None] | None = None
|
||||
self.tls_configured = False
|
||||
|
||||
def reconnect_delay_set(self, **_kwargs) -> None:
|
||||
return None
|
||||
|
||||
def ws_set_options(self, **kwargs: object) -> None:
|
||||
self.websocket_options.append(kwargs)
|
||||
|
||||
def username_pw_set(self, username: str, password: str | None) -> None:
|
||||
self.credentials = (username, password)
|
||||
|
||||
def tls_set(self) -> None:
|
||||
self.tls_configured = True
|
||||
|
||||
def connect_async(self, host: str, port: int, keepalive: int) -> None:
|
||||
self.connection = (host, port, keepalive)
|
||||
|
||||
@@ -89,6 +101,8 @@ def _settings():
|
||||
"password": None,
|
||||
"tls_enabled": False,
|
||||
"feedback_timeout_ms": 100,
|
||||
"transport": "tcp",
|
||||
"websocket_path": "/mqtt",
|
||||
}
|
||||
values["public_info"] = lambda: {
|
||||
"enabled": True,
|
||||
@@ -109,9 +123,31 @@ class MqttCanPayloadTests(unittest.TestCase):
|
||||
self.assertEqual(settings.condition_topic, "n7/n7-headunit/can/state")
|
||||
self.assertEqual(settings.control_topic, "n7/n7-headunit/can/command")
|
||||
self.assertFalse(settings.approval_required)
|
||||
self.assertEqual(settings.transport, "tcp")
|
||||
self.assertEqual(settings.websocket_path, "/mqtt")
|
||||
self.assertEqual(info["state_contract"], "n7-can-signals/v1")
|
||||
self.assertEqual(info["control_contract"], "n7-headunit-can-command/v1")
|
||||
|
||||
def test_websocket_transport_settings_are_loaded_from_environment(self) -> None:
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"COGNITIVE_AGENT_VEHICLE_MQTT_HOST": "mqtt.example.test",
|
||||
"COGNITIVE_AGENT_VEHICLE_MQTT_PORT": "443",
|
||||
"COGNITIVE_AGENT_VEHICLE_MQTT_TRANSPORT": "websocket",
|
||||
"COGNITIVE_AGENT_VEHICLE_MQTT_WEBSOCKET_PATH": "/mqtt",
|
||||
"COGNITIVE_AGENT_VEHICLE_MQTT_TLS": "true",
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
settings = VehicleMqttSettings.from_env()
|
||||
|
||||
self.assertTrue(settings.adapter_configured)
|
||||
self.assertEqual(settings.transport, "websockets")
|
||||
self.assertEqual(settings.websocket_path, "/mqtt")
|
||||
self.assertTrue(settings.tls_enabled)
|
||||
self.assertEqual(settings.public_info()["transport"], "websockets")
|
||||
|
||||
def test_explicit_shared_approval_policy_still_locks_mqtt_control(self) -> None:
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
@@ -461,6 +497,29 @@ class MqttCanTransportTests(unittest.TestCase):
|
||||
def tearDown(self) -> None:
|
||||
self.transport.close()
|
||||
|
||||
def test_websocket_client_applies_transport_path_authentication_and_tls(self) -> None:
|
||||
client = _FakeMqttClient()
|
||||
settings = _settings()
|
||||
settings.transport = "websockets"
|
||||
settings.websocket_path = "/mqtt"
|
||||
settings.username = "n7web"
|
||||
settings.password = "secret"
|
||||
settings.tls_enabled = True
|
||||
transport = MqttCanTransport(settings)
|
||||
|
||||
with patch("paho.mqtt.client.Client", return_value=client) as constructor:
|
||||
transport.start()
|
||||
try:
|
||||
self.assertEqual(
|
||||
constructor.call_args.kwargs["transport"], "websockets"
|
||||
)
|
||||
self.assertEqual(client.websocket_options, [{"path": "/mqtt"}])
|
||||
self.assertEqual(client.credentials, ("n7web", "secret"))
|
||||
self.assertTrue(client.tls_configured)
|
||||
self.assertEqual(client.connection, ("127.0.0.1", 1883, 30))
|
||||
finally:
|
||||
transport.close()
|
||||
|
||||
def test_condition_frames_use_existing_decoder_and_snapshot_contract(self) -> None:
|
||||
self.client.emit(
|
||||
"n7/n7-headunit/can/state",
|
||||
|
||||
@@ -137,7 +137,8 @@ def test_main_parsed_control_panel_renders_validated_infrared_operations() -> No
|
||||
assert 'capability === "infrared.emit"' in console
|
||||
assert 'channel: "IR"' in console
|
||||
assert 'frameLabel: "GSCU1B TCP"' in console
|
||||
assert "已解析控制指令" in console
|
||||
assert "下发控制指令" in console
|
||||
assert "已解析控制指令" not in console
|
||||
assert 'li[data-can-channel="IR"]' in styles
|
||||
assert "console-v2.js?v=delivery-state-62" in html
|
||||
assert "console-v2.js?v=local-first-66" in html
|
||||
assert 'speech_semantic: "语音语义"' in console
|
||||
|
||||
@@ -195,9 +195,15 @@ def test_left_sidebar_exposes_collapsed_parsed_can_command_panel() -> None:
|
||||
assert 'canCommandPanel.id = "v2CanCommandPanel"' in console
|
||||
assert 'id="v2CanCommandCount">0</strong>' in console
|
||||
assert 'id="v2CanCommandList"' in console
|
||||
assert "canCommandPanel.open" not in console
|
||||
assert 'id="v2CommandTiming"' in console
|
||||
assert "panel.open = true;" in console
|
||||
assert "if (ui.sidebarCollapsed) setSidebarCollapsed(false);" in console
|
||||
assert "function parsedCanCommandForOperation(operation = {})" in console
|
||||
assert "function parsedCanCommands(result = {})" in console
|
||||
assert "function semanticControlInstruction(operation = {})" in console
|
||||
assert "function compiledCommandsForOperation(result, configKey, operation = {})" in console
|
||||
assert "function parsedCommandTimingRows(result = {}, timingState = {})" in console
|
||||
assert "result.validated_control_commands" in console
|
||||
assert "getParsedCanCommands: (result) => parsedCanCommands(result)" in console
|
||||
assert "const configurations = result.function_configs || {};" in console
|
||||
assert "if (!configurationEntries.length && result.function_config)" in console
|
||||
@@ -206,8 +212,18 @@ def test_left_sidebar_exposes_collapsed_parsed_can_command_panel() -> None:
|
||||
assert 'const command = (category, text, channel = "CAN1", frame = "")' in console
|
||||
assert 'data-can-channel="${escapeHtml(item.channel)}"' in console
|
||||
assert 'class="v2-can-command-channel"' in console
|
||||
assert 'class="v2-can-command-layer v2-can-command-semantic"' in console
|
||||
assert 'class="v2-can-command-layer v2-can-command-actual"' in console
|
||||
assert console.index("语义控制指令") < console.index("实际控制指令")
|
||||
assert 'recordTiming: true' in console
|
||||
assert 'reveal: true' in console
|
||||
assert 'window.addEventListener("cognitive-agent:speech-state"' in console
|
||||
assert 'window.addEventListener("cognitive-agent:speech-result"' in console
|
||||
assert 'window.addEventListener("cognitive-agent:control-request-start"' in console
|
||||
assert 'window.addEventListener("cognitive-agent:result"' in console
|
||||
assert '$("#requestForm")?.addEventListener("submit"' in console
|
||||
assert "function commandResultMatchesTimedRequest(result = {})" in console
|
||||
assert "matchesTimedRequest && !validatedCommandsVisible" in console
|
||||
|
||||
for command_contract in (
|
||||
"door rear ${action}",
|
||||
@@ -246,6 +262,9 @@ def test_left_sidebar_exposes_collapsed_parsed_can_command_panel() -> None:
|
||||
assert ".v2-can-command-list li code" in styles
|
||||
assert ".v2-can-command-channel" in styles
|
||||
assert ".v2-can-command-delivery" in styles
|
||||
assert ".v2-command-timing" in styles
|
||||
assert ".v2-can-command-semantic" in styles
|
||||
assert ".v2-can-command-actual" in styles
|
||||
assert '.v2-can-command-delivery[data-delivery-state="submitted"]' in styles
|
||||
assert 'li[data-can-channel="CAN0"]' in styles
|
||||
assert ".v2-sidebar-collapsed .v2-can-command-panel" in styles
|
||||
@@ -297,10 +316,12 @@ def test_parsed_can_command_panel_combines_can0_seat_and_can1_vehicle_commands()
|
||||
"process.stdout.write(JSON.stringify(parsedCanCommands(sample)));"
|
||||
)
|
||||
completed = subprocess.run(
|
||||
[node, "-e", script],
|
||||
[node, "-"],
|
||||
input=script,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert json.loads(completed.stdout) == [
|
||||
@@ -310,6 +331,8 @@ def test_parsed_can_command_panel_combines_can0_seat_and_can1_vehicle_commands()
|
||||
"command": "seat passenger backrest position 36",
|
||||
"frame": "0x300 · 01 12 <SEQ_LO> <SEQ_HI> 10 0E 30 75",
|
||||
"parseSource": "local_rule",
|
||||
"semantic": "seat.set_backrest_recline(target=front-right, angle_deg=120, position_pct=36)",
|
||||
"operationRef": "",
|
||||
"deliveryState": "not_submitted",
|
||||
"deliveryLabel": "未下发",
|
||||
"deliveryTransport": "有线 CAN",
|
||||
@@ -319,6 +342,8 @@ def test_parsed_can_command_panel_combines_can0_seat_and_can1_vehicle_commands()
|
||||
"category": "空调温度",
|
||||
"command": "ac temp driver 24",
|
||||
"parseSource": "cloud_supplement",
|
||||
"semantic": "climate.set_temperature(target=driver, temperature_c=24)",
|
||||
"operationRef": "",
|
||||
"deliveryState": "not_submitted",
|
||||
"deliveryLabel": "未下发",
|
||||
"deliveryTransport": "有线 CAN",
|
||||
@@ -326,6 +351,335 @@ def test_parsed_can_command_panel_combines_can0_seat_and_can1_vehicle_commands()
|
||||
]
|
||||
|
||||
|
||||
def test_local_mixed_command_uses_compiled_can_and_ir_details_with_stage_timings() -> None:
|
||||
node = shutil.which("node")
|
||||
if node is None:
|
||||
return
|
||||
console = source("console-v2.js")
|
||||
start = console.index(" const normalizedCanTarget")
|
||||
end = console.index("\n\n function renderParsedCanCommands", start)
|
||||
helpers = console[start:end]
|
||||
sample = {
|
||||
"provider_used": "local_rules",
|
||||
"function_configs": {
|
||||
"native_climate": {
|
||||
"spec": {
|
||||
"domain": "climate",
|
||||
"operations": [
|
||||
{
|
||||
"id": "climate-temperature-1",
|
||||
"capability": "climate.set_temperature",
|
||||
"target": {"zone": "driver"},
|
||||
"parameters": {"temperature_c": 23},
|
||||
"parse_source": "local_rule",
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
"self_extension": {
|
||||
"spec": {
|
||||
"domain": "self_extension",
|
||||
"operations": [
|
||||
{
|
||||
"id": "infrared-1",
|
||||
"capability": "infrared.emit",
|
||||
"target": {"device": "toy-001", "name": "闪灯玩具"},
|
||||
"parameters": {"slot": 1, "action": "开启"},
|
||||
"parse_source": "local_rule",
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
},
|
||||
# Deliberately reverse the plan order and include a distracting command;
|
||||
# the renderer must join only on the exact operation_ref.
|
||||
"command_plans": {
|
||||
"self_extension": {
|
||||
"spec": {
|
||||
"domain": "self_extension",
|
||||
"commands": [
|
||||
{
|
||||
"operation_ref": "unrelated-ir",
|
||||
"message": "infrared.emit",
|
||||
"signals": {"slot": 9},
|
||||
},
|
||||
{
|
||||
"operation_ref": "infrared-1",
|
||||
"bus": "TCP",
|
||||
"message": "infrared.emit",
|
||||
"signals": {"slot": 1},
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
"native_climate": {
|
||||
"spec": {
|
||||
"domain": "climate",
|
||||
"commands": [
|
||||
{
|
||||
"operation_ref": "climate-temperature-1",
|
||||
"bus": "body_can",
|
||||
"message": "Native_0x1DE",
|
||||
"signals": {
|
||||
"debug_command": "ac temp driver 23",
|
||||
"frame_id": "0x1DE",
|
||||
"data_hex": "00 00 00 00 17 00 00 00",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
},
|
||||
"trace_events": [
|
||||
{"stage": "local_peripheral_plan", "duration_ms": 1},
|
||||
{"stage": "nfc_peripheral_sequence", "duration_ms": 0},
|
||||
{"stage": "intent_parse", "duration_ms": 12},
|
||||
{"stage": "context_resolve", "duration_ms": 1},
|
||||
{"stage": "compile", "duration_ms": 2},
|
||||
{"stage": "execute", "duration_ms": 999},
|
||||
],
|
||||
}
|
||||
timing_state = {
|
||||
"inputMode": "speech",
|
||||
"speechStartedAt": 0,
|
||||
"speechResultAt": 80,
|
||||
"speechTimings": {
|
||||
"audio_read_ms": 1,
|
||||
"vad_ms": 2,
|
||||
"asr_ms": 30,
|
||||
"assembly_ms": 1,
|
||||
"semantic_ms": 3,
|
||||
"command_resolution_ms": 1,
|
||||
"recovery_ms": 0,
|
||||
"total_ms": 37,
|
||||
},
|
||||
"requestStartedAt": 100,
|
||||
"validatedAt": 125,
|
||||
"displayedAt": 126,
|
||||
"renderDurationMs": 1,
|
||||
}
|
||||
browser_projection = {
|
||||
"provider_used": "local_rules+infrared_local_rules",
|
||||
"execution_mode": "dry_run",
|
||||
"validated_control_commands": [
|
||||
{
|
||||
"operation": sample["function_configs"]["native_climate"]["spec"][
|
||||
"operations"
|
||||
][0],
|
||||
"domain": "climate",
|
||||
"channel": "CAN1",
|
||||
"actual_command": "ac temp driver 23",
|
||||
"frames": [
|
||||
{
|
||||
"label": "CAN1 帧",
|
||||
"value": "0x1DE · 00 00 00 00 17 00 00 00",
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"operation": sample["function_configs"]["self_extension"]["spec"][
|
||||
"operations"
|
||||
][0],
|
||||
"domain": "self_extension",
|
||||
"channel": "IR",
|
||||
"actual_command": "infrared emit 1",
|
||||
"frames": [
|
||||
{
|
||||
"label": "GSCU1B TCP",
|
||||
"value": '{"action":"emit","data":{"no":1},"type":"infrared"}',
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
"trace_events": sample["trace_events"][:3],
|
||||
}
|
||||
script = (
|
||||
f"{helpers}\n"
|
||||
f"const sample = {json.dumps(sample, ensure_ascii=False)};\n"
|
||||
f"const browserProjection = {json.dumps(browser_projection, ensure_ascii=False)};\n"
|
||||
f"const timingState = {json.dumps(timing_state)};\n"
|
||||
"process.stdout.write(JSON.stringify({"
|
||||
"commands: parsedCanCommands(sample), "
|
||||
"projectedCommands: parsedCanCommands(browserProjection), "
|
||||
"timings: parsedCommandTimingRows(sample, timingState)"
|
||||
"}));"
|
||||
)
|
||||
completed = subprocess.run(
|
||||
[node, "-"],
|
||||
input=script,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
parsed = json.loads(completed.stdout)
|
||||
climate, infrared = parsed["commands"]
|
||||
projected_climate, projected_infrared = parsed["projectedCommands"]
|
||||
assert climate["semantic"] == (
|
||||
"climate.set_temperature(target=driver, temperature_c=23)"
|
||||
)
|
||||
assert climate["command"] == "ac temp driver 23"
|
||||
assert climate["frame"] == "0x1DE · 00 00 00 00 17 00 00 00"
|
||||
assert climate["frameLabel"] == "CAN1 帧"
|
||||
assert climate["parseSource"] == "local_rule"
|
||||
assert infrared["semantic"] == (
|
||||
"infrared.emit(target=toy-001, action=开启, slot=1)"
|
||||
)
|
||||
assert infrared["command"] == "infrared emit 1"
|
||||
assert infrared["frameLabel"] == "GSCU1B TCP"
|
||||
assert json.loads(infrared["frame"])["data"]["no"] == 1
|
||||
assert infrared["parseSource"] == "local_rule"
|
||||
assert projected_climate["semantic"] == climate["semantic"]
|
||||
assert projected_climate["command"] == climate["command"]
|
||||
assert projected_climate["frames"] == climate["frames"]
|
||||
assert projected_infrared["semantic"] == infrared["semantic"]
|
||||
assert projected_infrared["command"] == infrared["command"]
|
||||
assert projected_infrared["frames"] == infrared["frames"]
|
||||
|
||||
timing_by_key = {item["key"]: item for item in parsed["timings"]}
|
||||
assert timing_by_key["local_peripheral_plan-0"]["label"] == "红外本地规则编译"
|
||||
assert timing_by_key["nfc_peripheral_sequence-1"]["label"] == "红外指令序列生成"
|
||||
assert timing_by_key["intent_parse-2"]["label"] == "本地规则意图解析"
|
||||
assert timing_by_key["request_to_validated"]["durationMs"] == 25
|
||||
assert timing_by_key["dom_render"]["durationMs"] == 1
|
||||
assert timing_by_key["request_to_display"]["durationMs"] == 26
|
||||
assert timing_by_key["speech_to_display"]["durationMs"] == 126
|
||||
assert timing_by_key["total_ms"]["durationMs"] == 37
|
||||
assert all(item["durationMs"] >= 0 for item in parsed["timings"])
|
||||
assert not any(item["key"].startswith("execute") for item in parsed["timings"])
|
||||
|
||||
|
||||
def test_duplicate_operation_refs_do_not_borrow_another_configs_plan() -> None:
|
||||
node = shutil.which("node")
|
||||
if node is None:
|
||||
return
|
||||
console = source("console-v2.js")
|
||||
start = console.index(" const normalizedCanTarget")
|
||||
end = console.index("\n\n function renderParsedCanCommands", start)
|
||||
helpers = console[start:end]
|
||||
sample = {
|
||||
"function_configs": {
|
||||
"native_climate": {
|
||||
"spec": {
|
||||
"domain": "climate",
|
||||
"operations": [
|
||||
{
|
||||
"id": "op-1",
|
||||
"capability": "climate.set_temperature",
|
||||
"target": {"zone": "driver"},
|
||||
"parameters": {"temperature_c": 23},
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
"native_seat": {
|
||||
"spec": {
|
||||
"domain": "seat",
|
||||
"operations": [
|
||||
{
|
||||
"id": "op-1",
|
||||
"capability": "seat.set_heating",
|
||||
"target": {"zone": "passenger"},
|
||||
"parameters": {"level": 2},
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
},
|
||||
"command_plans": {
|
||||
"native_seat": {
|
||||
"spec": {
|
||||
"domain": "seat",
|
||||
"commands": [
|
||||
{
|
||||
"operation_ref": "op-1",
|
||||
"bus": "seat_can0",
|
||||
"signals": {
|
||||
"debug_command": "seat passenger heat 2",
|
||||
"frame_id": "0x300",
|
||||
"data_hex": "01 02 03 04 05 06 07 08",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
script = (
|
||||
f"{helpers}\n"
|
||||
f"const sample = {json.dumps(sample)};\n"
|
||||
"process.stdout.write(JSON.stringify(parsedCanCommands(sample)));"
|
||||
)
|
||||
|
||||
completed = subprocess.run(
|
||||
[node, "-"],
|
||||
input=script,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
parsed = json.loads(completed.stdout)
|
||||
by_semantic = {item["semantic"]: item for item in parsed}
|
||||
climate = by_semantic[
|
||||
"climate.set_temperature(target=driver, temperature_c=23)"
|
||||
]
|
||||
seat = by_semantic["seat.set_heating(target=passenger, level=2)"]
|
||||
assert climate["command"] == "ac temp driver 23"
|
||||
assert "frame" not in climate
|
||||
assert climate["channel"] == "CAN1"
|
||||
assert seat["command"] == "seat passenger heat 2"
|
||||
assert seat["channel"] == "CAN0"
|
||||
|
||||
|
||||
def test_command_timing_rejects_stale_request_events() -> None:
|
||||
node = shutil.which("node")
|
||||
if node is None:
|
||||
return
|
||||
console = source("console-v2.js")
|
||||
correlation_start = console.index(" function commandCorrelationIds")
|
||||
correlation_end = console.index(
|
||||
"\n\n function rememberValidatedCommandContext",
|
||||
correlation_start,
|
||||
)
|
||||
correlation_helper = console[correlation_start:correlation_end]
|
||||
start = console.index(" const normalizedCanTarget")
|
||||
end = console.index("\n\n function renderParsedCanCommands", start)
|
||||
helpers = console[start:end]
|
||||
script = (
|
||||
"let commandTimingState = {requestStartedAt: 1, requestId: '', "
|
||||
"utterance: '空调温度设置到23度,打开闪烁灯'};\n"
|
||||
f"{correlation_helper}\n"
|
||||
f"{helpers}\n"
|
||||
"const first = commandResultMatchesTimedRequest({request_id: 'req-current', "
|
||||
"utterance: '空调温度设置到23度,打开闪烁灯'});\n"
|
||||
"const staleId = commandResultMatchesTimedRequest({request_id: 'req-old', "
|
||||
"utterance: '空调温度设置到23度,打开闪烁灯'});\n"
|
||||
"const staleText = commandResultMatchesTimedRequest({request_id: 'req-current', "
|
||||
"utterance: '打开车窗'});\n"
|
||||
"process.stdout.write(JSON.stringify({first, staleId, staleText, "
|
||||
"boundRequestId: commandTimingState.requestId}));"
|
||||
)
|
||||
|
||||
completed = subprocess.run(
|
||||
[node, "-"],
|
||||
input=script,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert json.loads(completed.stdout) == {
|
||||
"first": True,
|
||||
"staleId": False,
|
||||
"staleText": False,
|
||||
"boundRequestId": "req-current",
|
||||
}
|
||||
|
||||
|
||||
def test_parsed_command_delivery_is_independent_of_transport_and_target() -> None:
|
||||
node = shutil.which("node")
|
||||
if node is None:
|
||||
@@ -398,10 +752,12 @@ def test_parsed_command_delivery_is_independent_of_transport_and_target() -> Non
|
||||
"parsedCanCommands(sample))));"
|
||||
)
|
||||
completed = subprocess.run(
|
||||
[node, "-e", script],
|
||||
[node, "-"],
|
||||
input=script,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
parsed = json.loads(completed.stdout)
|
||||
@@ -423,6 +779,7 @@ def test_parsed_command_delivery_is_independent_of_transport_and_target() -> Non
|
||||
|
||||
def test_mode_editor_renders_readable_commands_with_expandable_mqtt_sequences() -> None:
|
||||
app = source("app.js")
|
||||
console = source("console-v2.js")
|
||||
styles = source("styles.css")
|
||||
html = source("index.html")
|
||||
|
||||
@@ -442,6 +799,10 @@ def test_mode_editor_renders_readable_commands_with_expandable_mqtt_sequences()
|
||||
assert 'executionMode: "dry_run"' in app
|
||||
assert 'clientType: "mode_editor_compile"' in app
|
||||
assert "previewOnly: true" in app
|
||||
assert 'const nonDispatchTaskSources = new Set(["mode_editor_compile"])' in console
|
||||
assert "if (isDispatchTask(task))" in console
|
||||
assert "下发控制指令" in console
|
||||
assert "已解析控制指令" not in console
|
||||
assert "function executeSavedModeProfile(mode)" in app
|
||||
assert "/api/mode-profiles/${encodeURIComponent(mode.id)}/execute" in app
|
||||
assert '$("#utterance").value = mode.name || mode.id;' in app
|
||||
@@ -450,6 +811,9 @@ def test_mode_editor_renders_readable_commands_with_expandable_mqtt_sequences()
|
||||
|
||||
assert "生成指令" in html
|
||||
assert ">执行</span></button>" not in html
|
||||
assert 'id="hardwareNote"' not in html
|
||||
assert 'id="hardwareAliasNote"' not in html
|
||||
assert 'id="vehicleTransportNote"' not in html
|
||||
|
||||
assert ".mode-editor-command-card" in styles
|
||||
assert ".mode-editor-command-meta" in styles
|
||||
@@ -544,10 +908,12 @@ def test_mode_editor_prepares_one_to_many_mqtt_message_sequences() -> None:
|
||||
"process.stdout.write(JSON.stringify(modeEditorReadableCommands(flow)));"
|
||||
)
|
||||
completed = subprocess.run(
|
||||
[node, "-e", script],
|
||||
[node, "-"],
|
||||
input=script,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
commands = json.loads(completed.stdout)
|
||||
|
||||
@@ -619,10 +985,12 @@ def test_left_sidebar_extracts_speech_and_peripheral_control_commands() -> None:
|
||||
"}));"
|
||||
)
|
||||
completed = subprocess.run(
|
||||
[node, "-e", script],
|
||||
[node, "-"],
|
||||
input=script,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
output = json.loads(completed.stdout)
|
||||
|
||||
@@ -924,7 +1292,8 @@ def test_assistant_mode_preview_groups_validated_actions_for_the_card() -> None:
|
||||
"process.stdout.write(JSON.stringify(modeDraftRows(draft, actions)));"
|
||||
)
|
||||
completed = subprocess.run(
|
||||
[node, "-e", script],
|
||||
[node, "-"],
|
||||
input=script,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
|
||||
@@ -19,6 +19,57 @@ from cognitive_agent.application.codex_assistant import (
|
||||
CONTROL_TERMS = ("空调", "座椅", "车窗", "遮阳帘", "氛围灯", "阅读灯", "出风口")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("message", "mode_name"), [
|
||||
("构建一个观影模式", "观影模式"),
|
||||
("为主驾构建一个观影模式", "主驾观影模式"),
|
||||
])
|
||||
def test_local_first_callbacks_do_not_intercept_scoped_mode_draft_creation(message, mode_name):
|
||||
client = StructuredSceneClient()
|
||||
assistant = CodexAssistantService(client)
|
||||
events = []
|
||||
control_calls = []
|
||||
|
||||
def execute_control(instruction, _progress, plan):
|
||||
control_calls.append((instruction, plan))
|
||||
return _preview_result(mode_name)
|
||||
|
||||
result = assistant.stream(
|
||||
message, conversation_id="conv-merged-scene-router", principal="test",
|
||||
history=[], emit=events.append, execute_control=execute_control,
|
||||
preview_control=lambda *_args: pytest.fail("mode previews must not partially execute via local-first controls"),
|
||||
supplement_control=lambda *_args: pytest.fail("mode previews must not become unresolved control supplements"),
|
||||
control_terms=CONTROL_TERMS,
|
||||
capability_snapshot=_catalog_with_templates(_scene_template()),
|
||||
scene_capability_context=_scene_context(),
|
||||
)
|
||||
assert result["success"] is True
|
||||
assert result["route"] == "scene_template"
|
||||
assert [instruction for instruction, _ in control_calls] == [message]
|
||||
assert not client.structured_calls and not client.legacy_calls
|
||||
|
||||
|
||||
def test_local_first_callbacks_preserve_schedule_stop_route_and_answer():
|
||||
client = StructuredSceneClient()
|
||||
assistant = CodexAssistantService(client)
|
||||
events = []
|
||||
result = assistant.stream(
|
||||
"停止动态模式", conversation_id="conv-merged-schedule-stop", principal="test",
|
||||
history=[], emit=events.append,
|
||||
execute_control=lambda instruction, _progress: {
|
||||
"request_id": "req-stop-merged", "utterance": instruction,
|
||||
"status": "succeeded", "execution_mode": "dry_run",
|
||||
"orchestration_route": "mode_schedule_stop",
|
||||
"schedule_execution": {"status": "idle"},
|
||||
},
|
||||
preview_control=lambda *_args: pytest.fail("schedule stop must retain its scheduler route"),
|
||||
supplement_control=lambda *_args: pytest.fail("schedule stop is not an unresolved control"),
|
||||
control_terms=CONTROL_TERMS,
|
||||
)
|
||||
assert result["success"] is True
|
||||
assert "已模拟停止动态模式" in result["answer"]
|
||||
assert not client.structured_calls and not client.legacy_calls
|
||||
|
||||
|
||||
def _structured_plan(
|
||||
*,
|
||||
temperature: float = 24,
|
||||
@@ -798,6 +849,13 @@ def test_unknown_mode_uses_one_structured_turn_then_local_dry_run() -> None:
|
||||
"default_scope": "passenger",
|
||||
"applies_to": "seat capabilities in every phase",
|
||||
}
|
||||
expected_context["natural_language_routing"] = {
|
||||
"original_utterance": "构建一个星夜模式",
|
||||
"parsed_clauses": [],
|
||||
"resolved_clauses": [],
|
||||
"unresolved_clauses": ["构建一个星夜模式"],
|
||||
"cloud_input_utterance": "构建一个星夜模式",
|
||||
}
|
||||
assert call["application_context"] == expected_context
|
||||
assert call["output_schema"]["type"] == "object"
|
||||
assert call["output_schema"]["additionalProperties"] is False
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from cognitive_agent.application.seat_service import SeatDemoService
|
||||
@@ -47,6 +48,19 @@ def test_preview_validates_local_controls_without_calling_cloud(tmp_path: Path)
|
||||
assert result["status"] == "succeeded"
|
||||
assert result["supplement_required"] is True
|
||||
assert result["unresolved_clauses"] == ["播放音乐"]
|
||||
assert result["parsed_clauses"] == [
|
||||
"空调温度设置为24度",
|
||||
"风量设置为2档",
|
||||
]
|
||||
assert result["natural_language_routing"] == {
|
||||
"original_utterance": "空调温度设置为24度,风量设置为2档,播放音乐",
|
||||
"parsed_clauses": ["空调温度设置为24度", "风量设置为2档"],
|
||||
"resolved_clauses": ["空调温度设置为24度", "风量设置为2档"],
|
||||
"unresolved_clauses": ["播放音乐"],
|
||||
}
|
||||
assert result["local_control_budget_ms"] == 1000
|
||||
assert result["local_control_elapsed_ms"] < 1000
|
||||
assert result["local_control_within_budget"] is True
|
||||
assert [item["capability"] for item in _operations(result)] == [
|
||||
"climate.set_temperature",
|
||||
"climate.set_fan_level",
|
||||
@@ -58,6 +72,8 @@ def test_preview_validates_local_controls_without_calling_cloud(tmp_path: Path)
|
||||
assert validated_events[0]["phase"] == "deterministic_commands_validated"
|
||||
assert validated_events[0]["status"] == "processing"
|
||||
assert validated_events[0]["execution_report"] is None
|
||||
assert validated_events[0]["local_control_elapsed_ms"] < 1000
|
||||
assert validated_events[0]["local_control_within_budget"] is True
|
||||
assert _operations(validated_events[0]) == _operations(result)
|
||||
assert validated_events[0]["command_plans"]
|
||||
|
||||
@@ -119,12 +135,90 @@ def test_fully_local_cabin_request_streams_all_commands_without_supplement(
|
||||
assert _operations(validated_events[0]) == operations
|
||||
|
||||
|
||||
def test_qualitative_window_position_keeps_adjacent_reading_light_clause(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service = _service(tmp_path)
|
||||
cases = (
|
||||
("空调 29℃、打开阅读灯、副驾车窗开到20%", 20),
|
||||
("空调 29℃、打开阅读灯、副驾车窗开到一半", 50),
|
||||
)
|
||||
|
||||
for index, (utterance, expected_position) in enumerate(cases):
|
||||
result = service.preview_native_can(
|
||||
utterance,
|
||||
provider="local",
|
||||
conversation_id=f"conv-reading-window-{index:02d}",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
operations = _operations(result)
|
||||
by_capability = {item["capability"]: item for item in operations}
|
||||
|
||||
assert result["unresolved_clauses"] == []
|
||||
assert result["local_control_elapsed_ms"] < 1000
|
||||
assert result["local_control_within_budget"] is True
|
||||
assert result["parsed_clauses"] == [
|
||||
"空调 29℃",
|
||||
"打开阅读灯",
|
||||
utterance.rsplit("、", 1)[-1],
|
||||
]
|
||||
assert set(by_capability) == {
|
||||
"climate.set_temperature",
|
||||
"interior_light.set_reading_power",
|
||||
"window.set_position",
|
||||
}
|
||||
assert (
|
||||
by_capability["interior_light.set_reading_power"]["target"]["zone"]
|
||||
== "all"
|
||||
)
|
||||
assert by_capability["interior_light.set_reading_power"]["parameters"] == {
|
||||
"state": "on"
|
||||
}
|
||||
assert (
|
||||
by_capability["window.set_position"]["target"]["zone"]
|
||||
== "front_right"
|
||||
)
|
||||
assert by_capability["window.set_position"]["parameters"] == {
|
||||
"position_pct": expected_position
|
||||
}
|
||||
|
||||
|
||||
def test_local_window_parser_accepts_front_and_rear_row_side_aliases(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service = _service(tmp_path)
|
||||
cases = (
|
||||
("左前排车窗开到20%", "front_left", 20),
|
||||
("右前排窗开到一半", "front_right", 50),
|
||||
("左后排车窗开到35%", "rear_left", 35),
|
||||
("右后排窗开到一半", "rear_right", 50),
|
||||
)
|
||||
|
||||
for index, (utterance, expected_target, expected_position) in enumerate(cases):
|
||||
result = service.preview_native_can(
|
||||
utterance,
|
||||
provider="local",
|
||||
conversation_id=f"conv-window-alias-{index:02d}",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
operations = _operations(result)
|
||||
|
||||
assert result["status"] == "succeeded"
|
||||
assert result["unresolved_clauses"] == []
|
||||
assert len(operations) == 1
|
||||
assert operations[0]["capability"] == "window.set_position"
|
||||
assert operations[0]["target"]["zone"] == expected_target
|
||||
assert operations[0]["parameters"] == {
|
||||
"position_pct": expected_position
|
||||
}
|
||||
|
||||
|
||||
def test_supplement_receives_only_unresolved_text_and_deduplicates_local_control(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service = _service(tmp_path)
|
||||
preview = service.preview_native_can(
|
||||
"观影时空调温度设置为24度并播放音乐",
|
||||
"空调温度设置为24度并播放音乐",
|
||||
provider="openai",
|
||||
conversation_id="conv-two-stage-deduplicate",
|
||||
execution_mode="dry_run",
|
||||
@@ -169,7 +263,15 @@ def test_supplement_receives_only_unresolved_text_and_deduplicates_local_control
|
||||
provider="openai",
|
||||
)
|
||||
|
||||
assert interpret.call_args.args[0] == "观影时空调温度设置为24度并播放音乐"
|
||||
assert interpret.call_args.args[0] == "播放音乐"
|
||||
routing = interpret.call_args.args[1]["natural_language_routing"]
|
||||
assert routing == {
|
||||
"original_utterance": "空调温度设置为24度并播放音乐",
|
||||
"parsed_clauses": ["空调温度设置为24度"],
|
||||
"resolved_clauses": ["空调温度设置为24度"],
|
||||
"unresolved_clauses": ["播放音乐"],
|
||||
"cloud_input_utterance": "播放音乐",
|
||||
}
|
||||
operations = _operations(result)
|
||||
assert [item["capability"] for item in operations].count(
|
||||
"climate.set_temperature"
|
||||
@@ -217,6 +319,11 @@ def test_frontend_two_stage_contract_and_source_badges() -> None:
|
||||
assert 'item.deliveryLabel' in console
|
||||
assert 'data-delivery-state="${escapeHtml(item.deliveryState)}"' in console
|
||||
assert '"cognitive-agent:validated-can-commands"' in console
|
||||
assert "function commandCorrelationIds(result = {})" in console
|
||||
assert "result.preview_request_id" in console
|
||||
assert "result.supplement_of" in console
|
||||
assert '"local_control_elapsed"' in console
|
||||
assert '"本地规则解析与下发"' in console
|
||||
assert '.v2-can-command-source[data-parse-source="local_rule"]' in styles
|
||||
assert '.v2-can-command-delivery[data-delivery-state="submitted"]' in styles
|
||||
assert '"/api/can/commands"' in server
|
||||
@@ -287,6 +394,7 @@ def test_unified_result_preserves_cloud_infrared_attribution() -> None:
|
||||
"command_plans": {},
|
||||
"operation_results": [],
|
||||
"decision_summary": [],
|
||||
"trace_events": [{"stage": "compile", "duration_ms": 3}],
|
||||
}
|
||||
infrared_result = {
|
||||
"status": "succeeded",
|
||||
@@ -296,6 +404,10 @@ def test_unified_result_preserves_cloud_infrared_attribution() -> None:
|
||||
"command_plans": {},
|
||||
"operation_results": [],
|
||||
"decision_summary": [],
|
||||
"trace_events": [
|
||||
{"stage": "local_peripheral_plan", "duration_ms": 1},
|
||||
{"stage": "nfc_peripheral_sequence", "duration_ms": 0},
|
||||
],
|
||||
}
|
||||
entries = [
|
||||
{
|
||||
@@ -321,6 +433,368 @@ def test_unified_result_preserves_cloud_infrared_attribution() -> None:
|
||||
|
||||
assert merged["provider_used"] == "local_rules+gpt:gpt-5.6-luna"
|
||||
assert merged["infrared_actions"][0]["parse_source"] == "cloud_model"
|
||||
assert [event["stage"] for event in merged["trace_events"]] == [
|
||||
"local_peripheral_plan",
|
||||
"nfc_peripheral_sequence",
|
||||
"compile",
|
||||
]
|
||||
|
||||
|
||||
def test_pure_infrared_request_publishes_compiled_commands() -> None:
|
||||
handler = SeatDemoHandler.__new__(SeatDemoHandler)
|
||||
handler.service = SimpleNamespace(
|
||||
is_mode_profile_configuration_request=lambda _utterance: False,
|
||||
conversations=SimpleNamespace(snapshot=lambda _conversation_id: {}),
|
||||
persist_composed_result=lambda _result: None,
|
||||
)
|
||||
handler.extension = SimpleNamespace(list_peripheral_capabilities=lambda: [])
|
||||
handler.nfc = SimpleNamespace(snapshot=lambda: {})
|
||||
infrared_result = {
|
||||
"request_id": "req-ir-only",
|
||||
"utterance": "打开闪烁灯",
|
||||
"status": "succeeded",
|
||||
"function_configs": {
|
||||
"self_extension": {
|
||||
"spec": {
|
||||
"domain": "self_extension",
|
||||
"operations": [
|
||||
{
|
||||
"id": "infrared-1",
|
||||
"capability": "infrared.emit",
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
},
|
||||
"command_plans": {
|
||||
"self_extension": {
|
||||
"spec": {
|
||||
"domain": "self_extension",
|
||||
"commands": [
|
||||
{
|
||||
"operation_ref": "infrared-1",
|
||||
"message": "infrared.emit",
|
||||
"signals": {"slot": 1},
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
},
|
||||
"trace_events": [
|
||||
{"stage": "nfc_peripheral_route", "duration_ms": 0}
|
||||
],
|
||||
}
|
||||
handler._execute_local_peripheral_actions = (
|
||||
lambda _utterance, _entries, **_kwargs: infrared_result
|
||||
)
|
||||
validated_events: list[dict[str, object]] = []
|
||||
partition = {
|
||||
"peripheral_actions": [
|
||||
{
|
||||
"index": 0,
|
||||
"clause": "打开闪烁灯",
|
||||
"capability": {
|
||||
"id": "toy-001-light-on",
|
||||
"slot": 1,
|
||||
},
|
||||
}
|
||||
],
|
||||
"residual_utterance": "",
|
||||
"unresolved_peripheral_clauses": [],
|
||||
"denied_peripheral_actions": [],
|
||||
}
|
||||
|
||||
with patch(
|
||||
"cognitive_agent.server.partition_active_peripheral_intent",
|
||||
return_value=partition,
|
||||
):
|
||||
result = handler._execute_request_with_peripherals(
|
||||
"/api/requests/preview",
|
||||
{
|
||||
"utterance": "打开闪烁灯",
|
||||
"provider": "local",
|
||||
"execution_mode": "dry_run",
|
||||
},
|
||||
on_commands_validated=validated_events.append,
|
||||
)
|
||||
|
||||
assert result is infrared_result
|
||||
assert len(validated_events) == 2
|
||||
assert validated_events[0]["status"] == "processing"
|
||||
assert validated_events[0]["phase"] == "commands_validated"
|
||||
assert validated_events[0]["command_plans"] == infrared_result["command_plans"]
|
||||
assert validated_events[0]["trace_events"] == infrared_result["trace_events"]
|
||||
assert validated_events[1] is infrared_result
|
||||
|
||||
|
||||
def test_pure_unresolved_peripheral_returns_cloud_ready_local_preview(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
handler = SeatDemoHandler.__new__(SeatDemoHandler)
|
||||
handler.service = _service(tmp_path)
|
||||
handler.extension = SimpleNamespace(list_peripheral_capabilities=lambda: [])
|
||||
handler.nfc = SimpleNamespace(snapshot=lambda: {})
|
||||
validated_events: list[dict[str, object]] = []
|
||||
utterance = "让闪烁灯喜庆一点"
|
||||
partition = {
|
||||
"peripheral_actions": [],
|
||||
"residual_utterance": "",
|
||||
"unresolved_peripheral_clauses": [utterance],
|
||||
"unresolved_candidates": [
|
||||
{
|
||||
"id": "light-festive",
|
||||
"status": "learned",
|
||||
"device_id": "toy-001",
|
||||
"device_name": "闪烁灯",
|
||||
"action": "喜庆灯效",
|
||||
"slot": 2,
|
||||
}
|
||||
],
|
||||
"denied_peripheral_actions": [],
|
||||
}
|
||||
|
||||
with patch(
|
||||
"cognitive_agent.server.partition_active_peripheral_intent",
|
||||
return_value=partition,
|
||||
):
|
||||
result = handler._execute_request_with_peripherals(
|
||||
"/api/requests/preview",
|
||||
{
|
||||
"utterance": utterance,
|
||||
"provider": "local",
|
||||
"conversation_id": "conv-unresolved-peripheral-preview",
|
||||
"execution_mode": "dry_run",
|
||||
},
|
||||
on_commands_validated=validated_events.append,
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result["status"] == "no_action"
|
||||
assert result["supplement_required"] is True
|
||||
assert result["function_configs"] == {}
|
||||
assert result["command_plans"] == {}
|
||||
assert result["preview_request_id"] == result["request_id"]
|
||||
assert result["local_control_budget_ms"] == 1000
|
||||
assert result["local_control_elapsed_ms"] < 1000
|
||||
assert result["local_control_within_budget"] is True
|
||||
assert result["natural_language_routing"] == {
|
||||
"original_utterance": utterance,
|
||||
"parsed_clauses": [],
|
||||
"resolved_clauses": [],
|
||||
"unresolved_clauses": [utterance],
|
||||
}
|
||||
assert validated_events == [result]
|
||||
assert (
|
||||
handler.service.artifact_root
|
||||
/ str(result["request_id"])
|
||||
/ "result.json"
|
||||
).is_file()
|
||||
|
||||
|
||||
def test_mixed_request_finishes_all_local_phases_before_cloud_ir() -> None:
|
||||
handler = SeatDemoHandler.__new__(SeatDemoHandler)
|
||||
order: list[str] = []
|
||||
validated_events: list[dict[str, object]] = []
|
||||
local_capability = {
|
||||
"id": "light-on",
|
||||
"status": "learned",
|
||||
"device_id": "toy-001",
|
||||
"device_name": "闪烁灯",
|
||||
"card_uid": "A1B2",
|
||||
"action": "打开",
|
||||
"slot": 1,
|
||||
}
|
||||
cloud_capability = {
|
||||
**local_capability,
|
||||
"id": "light-festive",
|
||||
"action": "喜庆灯效",
|
||||
"slot": 2,
|
||||
}
|
||||
|
||||
def peripheral_result(
|
||||
request_id: str,
|
||||
capability: dict[str, object],
|
||||
parse_source: str,
|
||||
) -> dict[str, object]:
|
||||
operation_id = str(capability["id"])
|
||||
return {
|
||||
"request_id": request_id,
|
||||
"utterance": "空调温度设置到23度,打开闪烁灯,让闪烁灯喜庆一点",
|
||||
"status": "succeeded",
|
||||
"success": True,
|
||||
"provider_used": (
|
||||
"infrared_local_rules"
|
||||
if parse_source == "local_rule"
|
||||
else "gpt:test"
|
||||
),
|
||||
"execution_mode": "dry_run",
|
||||
"control_transport": "gscu_dry_run",
|
||||
"function_configs": {
|
||||
"self_extension": {
|
||||
"spec": {
|
||||
"domain": "self_extension",
|
||||
"operations": [
|
||||
{
|
||||
"id": operation_id,
|
||||
"capability": "infrared.emit",
|
||||
"parse_source": parse_source,
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
},
|
||||
"command_plans": {
|
||||
"self_extension": {
|
||||
"spec": {
|
||||
"domain": "self_extension",
|
||||
"commands": [
|
||||
{
|
||||
"operation_ref": operation_id,
|
||||
"message": "infrared.emit",
|
||||
"signals": {"slot": capability["slot"]},
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
},
|
||||
"trace_events": [],
|
||||
"validation": [],
|
||||
"operation_results": [],
|
||||
"decision_summary": [],
|
||||
"execution_report": None,
|
||||
}
|
||||
|
||||
local_result = peripheral_result(
|
||||
"req-local-ir",
|
||||
local_capability,
|
||||
"local_rule",
|
||||
)
|
||||
cloud_result = peripheral_result(
|
||||
"req-cloud-ir",
|
||||
cloud_capability,
|
||||
"cloud_model",
|
||||
)
|
||||
can_result = {
|
||||
"request_id": "req-local-can",
|
||||
"utterance": "空调温度设置到23度",
|
||||
"status": "succeeded",
|
||||
"success": True,
|
||||
"provider_used": "local_rules",
|
||||
"execution_mode": "dry_run",
|
||||
"control_transport": "wired_can",
|
||||
"function_configs": {},
|
||||
"command_plans": {},
|
||||
"trace_events": [],
|
||||
"validation": [],
|
||||
"operation_results": [],
|
||||
"decision_summary": [],
|
||||
"execution_report": None,
|
||||
"natural_language_routing": {
|
||||
"original_utterance": "空调温度设置到23度,打开闪烁灯,让闪烁灯喜庆一点",
|
||||
"parsed_clauses": ["打开闪烁灯", "空调温度设置到23度"],
|
||||
"resolved_clauses": ["打开闪烁灯", "空调温度设置到23度"],
|
||||
"unresolved_clauses": ["让闪烁灯喜庆一点"],
|
||||
},
|
||||
}
|
||||
|
||||
def preview_native_can(**kwargs: object) -> dict[str, object]:
|
||||
order.append("local_can")
|
||||
callback = kwargs.get("on_commands_validated")
|
||||
if callable(callback):
|
||||
callback(dict(can_result))
|
||||
return dict(can_result)
|
||||
|
||||
handler.service = SimpleNamespace(
|
||||
is_mode_profile_configuration_request=lambda _utterance: False,
|
||||
conversations=SimpleNamespace(snapshot=lambda _conversation_id: {}),
|
||||
preview_native_can=preview_native_can,
|
||||
persist_composed_result=lambda _result: None,
|
||||
)
|
||||
|
||||
def plan(payload: dict[str, object]) -> dict[str, object]:
|
||||
order.append("cloud_plan")
|
||||
assert payload["utterance"] == "让闪烁灯喜庆一点"
|
||||
routing = payload["natural_language_routing"]
|
||||
assert isinstance(routing, dict)
|
||||
assert routing["original_utterance"] == (
|
||||
"空调温度设置到23度,打开闪烁灯,让闪烁灯喜庆一点"
|
||||
)
|
||||
assert routing["parsed_clauses"] == [
|
||||
"打开闪烁灯",
|
||||
"空调温度设置到23度",
|
||||
]
|
||||
return {
|
||||
"steps": [
|
||||
{"kind": "peripheral", "capability_id": "light-festive"}
|
||||
]
|
||||
}
|
||||
|
||||
def execute_cloud(
|
||||
_utterance: str,
|
||||
_plan: dict[str, object],
|
||||
catalog: list[dict[str, object]],
|
||||
**_kwargs: object,
|
||||
) -> dict[str, object]:
|
||||
order.append("cloud_ir")
|
||||
assert [item["id"] for item in catalog] == ["light-festive"]
|
||||
return cloud_result
|
||||
|
||||
handler.extension = SimpleNamespace(
|
||||
list_peripheral_capabilities=lambda: [],
|
||||
plan=plan,
|
||||
execute_peripheral_plan_request=execute_cloud,
|
||||
)
|
||||
handler.nfc = SimpleNamespace(snapshot=lambda: {})
|
||||
|
||||
def execute_local(
|
||||
_utterance: str,
|
||||
_entries: list[dict[str, object]],
|
||||
**kwargs: object,
|
||||
) -> dict[str, object]:
|
||||
order.append("local_ir")
|
||||
callback = kwargs.get("on_commands_validated")
|
||||
if callable(callback):
|
||||
callback(local_result)
|
||||
return local_result
|
||||
|
||||
handler._execute_local_peripheral_actions = execute_local
|
||||
partition = {
|
||||
"peripheral_actions": [
|
||||
{"index": 1, "clause": "打开闪烁灯", "capability": local_capability}
|
||||
],
|
||||
"residual_utterance": "空调温度设置到23度",
|
||||
"unresolved_peripheral_clauses": ["让闪烁灯喜庆一点"],
|
||||
"unresolved_candidates": [cloud_capability],
|
||||
"denied_peripheral_actions": [],
|
||||
}
|
||||
|
||||
with patch(
|
||||
"cognitive_agent.server.partition_active_peripheral_intent",
|
||||
return_value=partition,
|
||||
):
|
||||
result = handler._execute_request_with_peripherals(
|
||||
"/api/requests/preview",
|
||||
{
|
||||
"utterance": (
|
||||
"空调温度设置到23度,打开闪烁灯,让闪烁灯喜庆一点"
|
||||
),
|
||||
"provider": "auto",
|
||||
"conversation_id": "conv-mixed-order",
|
||||
"execution_mode": "dry_run",
|
||||
},
|
||||
on_commands_validated=validated_events.append,
|
||||
)
|
||||
|
||||
assert order == ["local_ir", "local_can", "cloud_plan", "cloud_ir"]
|
||||
assert result is not None
|
||||
assert result["status"] == "succeeded"
|
||||
assert any(event.get("local_control_within_budget") for event in validated_events)
|
||||
operation_sources = {
|
||||
operation["parse_source"]
|
||||
for config in result["function_configs"].values()
|
||||
for operation in config["spec"].get("operations", [])
|
||||
}
|
||||
assert operation_sources == {"local_rule", "cloud_model"}
|
||||
|
||||
|
||||
def test_nfc_command_denial_keeps_allowed_result_and_reports_partial() -> None:
|
||||
|
||||
Reference in New Issue
Block a user