feat: 完善 N7 智能座舱交互与控制链路
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from cognitive_agent.application.codex_assistant import CodexAssistantService
|
||||
from cognitive_agent.application.domain_runtime import ConfiguredDomainRuntime
|
||||
from cognitive_agent.application.mode_profile import ModeProfileRegistry
|
||||
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
|
||||
@@ -791,6 +792,12 @@ operations:
|
||||
},
|
||||
},
|
||||
)
|
||||
saved_public_mode = next(
|
||||
mode
|
||||
for mode in self.service.mode_profiles.public_modes("denza_n7")
|
||||
if mode["id"] == "nap"
|
||||
)
|
||||
self.assertEqual(saved_public_mode["behavior"], "cyclic")
|
||||
|
||||
with patch.object(
|
||||
self.service,
|
||||
@@ -824,7 +831,7 @@ operations:
|
||||
}
|
||||
self.assertIn("roof.set_shade_motion", replayed_capabilities)
|
||||
|
||||
def test_build_named_mode_executes_and_persists_in_one_turn(self) -> None:
|
||||
def test_build_named_mode_returns_draft_before_explicit_save(self) -> None:
|
||||
mode_root = Path(self.temporary_directory.name) / "modes"
|
||||
self.service.mode_profiles = ModeProfileRegistry(mode_root)
|
||||
model_output = {
|
||||
@@ -864,9 +871,41 @@ operations:
|
||||
)
|
||||
|
||||
self.assertEqual(built["status"], "succeeded")
|
||||
self.assertEqual(built["mode_profile"]["name"], "K歌模式")
|
||||
self.assertIsNone(built["mode_profile"])
|
||||
self.assertEqual(
|
||||
built["orchestration_route"], "mode_profile_create_preview"
|
||||
)
|
||||
draft = built["mode_profile_draft"]
|
||||
self.assertEqual(draft["name"], "K歌模式")
|
||||
self.assertEqual(draft["operation_count"], 2)
|
||||
self.assertEqual(set(draft["domains"]), {"ambient_light", "climate"})
|
||||
self.assertEqual(built["mode_creation"]["source_status"], "succeeded")
|
||||
self.assertEqual(built["mode_creation"]["operation_count"], 2)
|
||||
self.assertNotIn(
|
||||
"K歌模式",
|
||||
{
|
||||
mode["name"]
|
||||
for mode in self.service.runtime_info()["modes"]
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
self.service.mode_profiles.match_text(
|
||||
"进入K歌模式", vehicle_id="denza_n7"
|
||||
),
|
||||
[],
|
||||
)
|
||||
|
||||
saved = self.service.create_mode_profile_card(
|
||||
draft["name"],
|
||||
draft["instruction"],
|
||||
draft["source_request_id"],
|
||||
draft["vehicle_id"],
|
||||
)
|
||||
|
||||
self.assertEqual(saved["status"], "succeeded")
|
||||
self.assertEqual(saved["mode_profile"]["name"], "K歌模式")
|
||||
self.assertEqual(saved["mode_profile"]["operation_count"], 2)
|
||||
self.assertTrue(saved["mode_profile"]["command_flow"]["plans"])
|
||||
self.assertIn(
|
||||
"K歌模式",
|
||||
{
|
||||
@@ -878,7 +917,7 @@ operations:
|
||||
"进入K歌模式", vehicle_id="denza_n7"
|
||||
)
|
||||
self.assertEqual([profile.name for profile in matched], ["K歌模式"])
|
||||
self.assertTrue(Path(built["mode_profile"]["source"]).is_file())
|
||||
self.assertTrue(Path(matched[0].source).is_file())
|
||||
|
||||
def test_build_named_mode_is_always_a_dry_run_configuration_workflow(self) -> None:
|
||||
mode_root = Path(self.temporary_directory.name) / "hardware-modes"
|
||||
@@ -916,7 +955,208 @@ operations:
|
||||
self.assertEqual(built["status"], "succeeded")
|
||||
self.assertEqual(built["execution_mode"], "dry_run")
|
||||
self.assertFalse(built["hardware_execution_allowed"])
|
||||
self.assertEqual(built["mode_profile"]["name"], "K歌模式")
|
||||
self.assertIsNone(built["mode_profile"])
|
||||
self.assertEqual(built["mode_profile_draft"]["name"], "K歌模式")
|
||||
self.assertEqual(
|
||||
built["orchestration_route"], "mode_profile_create_preview"
|
||||
)
|
||||
self.assertNotIn(
|
||||
"K歌模式",
|
||||
{
|
||||
mode["name"]
|
||||
for mode in self.service.runtime_info()["modes"]
|
||||
},
|
||||
)
|
||||
|
||||
def test_build_named_mode_uses_the_user_supplied_scene_requirements(self) -> None:
|
||||
mode_root = Path(self.temporary_directory.name) / "described-modes"
|
||||
self.service.mode_profiles = ModeProfileRegistry(mode_root)
|
||||
model_output = {
|
||||
"detected_domains": ["climate"],
|
||||
"requests": [
|
||||
{
|
||||
"domain": "climate",
|
||||
"capability": "climate.set_temperature",
|
||||
"target_scope": "vehicle",
|
||||
"operation": "set",
|
||||
"parameters": [{"name": "temperature_c", "value": 24}],
|
||||
"reason": "保持助眠温度。",
|
||||
}
|
||||
],
|
||||
"ambiguities": [],
|
||||
"decision_summary": ["已按用户描述构建助眠场景。"],
|
||||
"confidence": 0.95,
|
||||
}
|
||||
|
||||
with patch.object(
|
||||
self.service.openai,
|
||||
"interpret",
|
||||
return_value=model_output,
|
||||
) as interpret:
|
||||
built = self.service.process(
|
||||
"创建宝宝睡眠模式:营造安静的助眠氛围",
|
||||
"openai",
|
||||
"conv-build-described-mode",
|
||||
vehicle_id="denza_n7",
|
||||
)
|
||||
|
||||
self.assertEqual(built["status"], "succeeded")
|
||||
self.assertIsNone(built["mode_profile"])
|
||||
self.assertEqual(built["mode_profile_draft"]["name"], "宝宝睡眠模式")
|
||||
self.assertEqual(
|
||||
built["mode_profile_draft"]["instruction"], "营造安静的助眠氛围"
|
||||
)
|
||||
self.assertEqual(
|
||||
built["orchestration_route"], "mode_profile_create_preview"
|
||||
)
|
||||
self.assertEqual(interpret.call_args.args[0], "营造安静的助眠氛围")
|
||||
|
||||
def test_movie_mode_preview_and_confirmed_save_keep_all_nine_actions(
|
||||
self,
|
||||
) -> None:
|
||||
user_root = Path(self.temporary_directory.name) / "movie-mode-user"
|
||||
self.service.mode_profiles = ModeProfileRegistry(
|
||||
REPOSITORY_ROOT / "config" / "modes",
|
||||
user_root=user_root,
|
||||
)
|
||||
utterance = (
|
||||
"构建一个观影模式,遮阳帘关闭,车窗关闭,"
|
||||
"副驾座椅调整为零重力模式,灯光全部关闭"
|
||||
)
|
||||
|
||||
built = self.service.preview_native_can(
|
||||
utterance,
|
||||
provider="local",
|
||||
conversation_id="conv-build-movie-mode",
|
||||
vehicle_id="denza_n7",
|
||||
)
|
||||
|
||||
self.assertEqual(built["status"], "succeeded")
|
||||
self.assertEqual(
|
||||
built["orchestration_route"], "mode_profile_create_preview"
|
||||
)
|
||||
self.assertEqual(built["unresolved_clauses"], [])
|
||||
self.assertIsNone(built["mode_profile"])
|
||||
draft = built["mode_profile_draft"]
|
||||
self.assertEqual(draft["name"], "观影模式")
|
||||
self.assertEqual(draft["operation_count"], 9)
|
||||
self.assertEqual(
|
||||
set(draft["domains"]),
|
||||
{"seat", "roof", "window", "interior_light", "ambient_light"},
|
||||
)
|
||||
self.assertNotIn(
|
||||
"观影模式",
|
||||
{mode["name"] for mode in self.service.runtime_info()["modes"]},
|
||||
)
|
||||
|
||||
intent_actions = built["intent_ir"]["spec"]["actions"]
|
||||
capabilities = [action["capability"] for action in intent_actions]
|
||||
self.assertEqual(len(capabilities), 9)
|
||||
self.assertEqual(
|
||||
set(capabilities),
|
||||
{
|
||||
"seat.set_longitudinal_position",
|
||||
"seat.set_cushion_tilt",
|
||||
"seat.set_backrest_recline",
|
||||
"seat.set_legrest_height",
|
||||
"roof.set_shade_motion",
|
||||
"window.set_motion",
|
||||
"interior_light.set_indoor_power",
|
||||
"interior_light.set_reading_power",
|
||||
"ambient_light.set_power",
|
||||
},
|
||||
)
|
||||
window_action = next(
|
||||
action
|
||||
for action in intent_actions
|
||||
if action["capability"] == "window.set_motion"
|
||||
)
|
||||
self.assertEqual(window_action["target"]["zone"], "front_left")
|
||||
|
||||
saved = self.service.create_mode_profile_card(
|
||||
draft["name"],
|
||||
draft["instruction"],
|
||||
draft["source_request_id"],
|
||||
draft["vehicle_id"],
|
||||
)
|
||||
|
||||
self.assertEqual(saved["status"], "succeeded")
|
||||
profile = saved["mode_profile"]
|
||||
self.assertEqual(profile["name"], "观影模式")
|
||||
self.assertEqual(profile["origin"], "user")
|
||||
self.assertEqual(profile["operation_count"], 9)
|
||||
self.assertEqual(
|
||||
sum(
|
||||
len(config["spec"]["operations"])
|
||||
for config in profile["command_flow"]["function_configs"].values()
|
||||
),
|
||||
9,
|
||||
)
|
||||
self.assertEqual(
|
||||
set(profile["command_flow"]["plans"]),
|
||||
set(profile["command_flow"]["function_configs"]),
|
||||
)
|
||||
persisted = self.service.mode_profiles.get_profile(
|
||||
profile["id"], "denza_n7"
|
||||
)
|
||||
self.assertTrue(Path(persisted.source).is_file())
|
||||
|
||||
executed = self.service.execute_mode_profile_card(
|
||||
profile["id"],
|
||||
conversation_id="conv-execute-movie-mode",
|
||||
execution_mode="dry_run",
|
||||
vehicle_id="denza_n7",
|
||||
)
|
||||
self.assertEqual(executed["status"], "succeeded")
|
||||
self.assertEqual(executed["orchestration_route"], "saved_mode_command_flow")
|
||||
self.assertEqual(len(executed["execution_report"]["spec"]["results"]), 9)
|
||||
|
||||
def test_movie_mode_assistant_route_uses_real_builder_without_codex(self) -> None:
|
||||
user_root = Path(self.temporary_directory.name) / "assistant-movie-mode-user"
|
||||
self.service.mode_profiles = ModeProfileRegistry(
|
||||
REPOSITORY_ROOT / "config" / "modes",
|
||||
user_root=user_root,
|
||||
)
|
||||
utterance = (
|
||||
"构建一个观影模式,遮阳帘关闭,车窗关闭,"
|
||||
"副驾座椅调整为零重力模式,灯光全部关闭"
|
||||
)
|
||||
codex_client = MagicMock()
|
||||
codex_client.available = False
|
||||
assistant = CodexAssistantService(codex_client)
|
||||
events: list[dict[str, object]] = []
|
||||
|
||||
def execute_control(instruction: str, progress: object) -> dict[str, object]:
|
||||
callback = progress if callable(progress) else None
|
||||
return self.service.preview_native_can(
|
||||
instruction,
|
||||
provider="local",
|
||||
conversation_id="conv-assistant-movie-mode",
|
||||
execution_mode="hardware",
|
||||
vehicle_id="denza_n7",
|
||||
on_commands_validated=callback,
|
||||
)
|
||||
|
||||
result = assistant.stream(
|
||||
utterance,
|
||||
conversation_id="conv-assistant-movie-mode",
|
||||
principal="integration-test",
|
||||
history=[],
|
||||
emit=lambda event: events.append(event) or True,
|
||||
execute_control=execute_control,
|
||||
)
|
||||
|
||||
self.assertEqual(result["route"], "vehicle_control")
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(result["result"]["execution_mode"], "dry_run")
|
||||
self.assertEqual(result["result"]["mode_profile_draft"]["name"], "观影模式")
|
||||
self.assertEqual(result["result"]["mode_profile_draft"]["operation_count"], 9)
|
||||
self.assertEqual(len(result["result"]["actions"]), 9)
|
||||
self.assertEqual(events[0]["engine"], "n7web-mode-builder")
|
||||
self.assertEqual(events[-1]["route"], "vehicle_control")
|
||||
self.assertIn("观影模式", str(events[-1]["answer"]))
|
||||
self.assertFalse(any(user_root.glob("*.yaml")))
|
||||
codex_client.start_turn.assert_not_called()
|
||||
|
||||
def test_create_mode_language_does_not_match_ordinary_mode_execution(self) -> None:
|
||||
self.assertEqual(
|
||||
@@ -927,6 +1167,18 @@ operations:
|
||||
self.service._parse_mode_profile_create_name("新增冥想空间模式"),
|
||||
"冥想空间模式",
|
||||
)
|
||||
self.assertEqual(
|
||||
self.service._parse_mode_profile_create_name(
|
||||
"创建宝宝睡眠模式:副驾半躺、空调24度、关闭阅读灯"
|
||||
),
|
||||
"宝宝睡眠模式",
|
||||
)
|
||||
self.assertEqual(
|
||||
self.service._parse_mode_profile_create_name(
|
||||
"请帮我创建一个 宝宝睡眠模式:副驾半躺"
|
||||
),
|
||||
"宝宝睡眠模式",
|
||||
)
|
||||
self.assertIsNone(
|
||||
self.service._parse_mode_profile_create_name("进入K歌模式")
|
||||
)
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from cognitive_agent.application.codex_assistant import (
|
||||
CodexAssistantService,
|
||||
_authorize_vehicle_request,
|
||||
)
|
||||
from cognitive_agent.application.native_can_runtime import NativeCanExecutor
|
||||
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
|
||||
from cognitive_agent.infrastructure.speech.understanding import (
|
||||
UnderstandingTranscriptProcessor,
|
||||
)
|
||||
from tests.unit.test_codex_assistant_service import CONTROL_TERMS, FakeCodexClient
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def speech_processor() -> UnderstandingTranscriptProcessor:
|
||||
return UnderstandingTranscriptProcessor(REPOSITORY_ROOT)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("spoken_duration", "duration_ms"),
|
||||
(
|
||||
("3s", 3_000),
|
||||
("3秒", 3_000),
|
||||
("三秒", 3_000),
|
||||
("1.5s", 1_500),
|
||||
("1.5秒", 1_500),
|
||||
),
|
||||
)
|
||||
@pytest.mark.parametrize("backrest_alias", ("座椅靠背", "椅背"))
|
||||
def test_spoken_preview_keeps_explicit_timed_backrest_semantics(
|
||||
speech_processor: UnderstandingTranscriptProcessor,
|
||||
spoken_duration: str,
|
||||
duration_ms: int,
|
||||
backrest_alias: str,
|
||||
) -> None:
|
||||
raw = (
|
||||
f"主驾{backrest_alias}向后移动{spoken_duration},"
|
||||
"先不要下发,给出解析的控制指令"
|
||||
)
|
||||
|
||||
result = speech_processor.process(raw)
|
||||
|
||||
assert len(result.commands) == 1
|
||||
command = result.commands[0]
|
||||
assert command.capability == "seat.set_driver_axis_native"
|
||||
assert command.target_scope == "driver"
|
||||
assert command.operation == "backward"
|
||||
assert command.parameters == {
|
||||
"axis": "backrest",
|
||||
"direction": "backward",
|
||||
"duration_ms": duration_ms,
|
||||
}
|
||||
seconds = f"{duration_ms / 1_000:g}秒"
|
||||
assert seconds in result.clean_text
|
||||
assert seconds in result.assistant_text
|
||||
assert f"{duration_ms / 1_000:g}度" not in result.assistant_text
|
||||
assert "不要下发" in result.assistant_text
|
||||
assert "解析的控制指令" in result.assistant_text
|
||||
assert result.execution_eligible is False
|
||||
|
||||
|
||||
def test_reported_three_second_preview_is_preserved_exactly_and_fails_closed(
|
||||
speech_processor: UnderstandingTranscriptProcessor,
|
||||
) -> None:
|
||||
raw = "主驾座椅靠背向后移动3s,先不要下发,给出解析的控制指令"
|
||||
|
||||
result = speech_processor.process(raw)
|
||||
|
||||
assert result.clean_text == "将主驾座椅靠背向后移动3秒。"
|
||||
assert result.assistant_text == (
|
||||
"将主驾座椅靠背向后移动3秒,先不要下发,给出解析的控制指令。"
|
||||
)
|
||||
command = result.commands[0]
|
||||
assert command.resolution_status == "unsupported"
|
||||
assert any("高于工程上限2000" in issue for issue in command.validation_issues)
|
||||
assert result.semantic_status == "recognized_unsupported"
|
||||
assert result.uncertain is True
|
||||
assert result.execution_eligible is False
|
||||
|
||||
|
||||
def test_reported_three_second_preview_cannot_enter_the_service_execution_bridge(
|
||||
tmp_path: Path,
|
||||
speech_processor: UnderstandingTranscriptProcessor,
|
||||
) -> None:
|
||||
transcript = speech_processor.process(
|
||||
"主驾座椅靠背向后移动3s,先不要下发,给出解析的控制指令"
|
||||
)
|
||||
understanding = transcript.to_dict()
|
||||
understanding["utterance_id"] = "reported-timed-preview-safety-gate"
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
service.artifact_root = tmp_path / "artifacts"
|
||||
|
||||
with patch.object(
|
||||
NativeCanExecutor,
|
||||
"execute",
|
||||
side_effect=AssertionError("preview-only speech reached physical CAN"),
|
||||
) as physical_execute, pytest.raises(ValueError, match="not execution eligible"):
|
||||
service.process(
|
||||
transcript.clean_text,
|
||||
provider="local",
|
||||
conversation_id="conv-reported-timed-preview-gate",
|
||||
execution_mode="dry_run",
|
||||
vehicle_id="denza_n7",
|
||||
_speech_understanding=understanding,
|
||||
)
|
||||
|
||||
assert physical_execute.call_count == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"preview_request",
|
||||
(
|
||||
"先不要下发,给出解析的控制指令",
|
||||
"只解析不要执行",
|
||||
"仅预览",
|
||||
"不要执行",
|
||||
"不下发",
|
||||
"不要发送到车辆",
|
||||
"只给出控制指令",
|
||||
"仅返回解析结果",
|
||||
),
|
||||
)
|
||||
def test_preview_only_phrases_cannot_authorize_vehicle_execution(
|
||||
speech_processor: UnderstandingTranscriptProcessor,
|
||||
preview_request: str,
|
||||
) -> None:
|
||||
result = speech_processor.process(
|
||||
f"主驾座椅靠背向后移动2秒,{preview_request}"
|
||||
)
|
||||
|
||||
authorization = _authorize_vehicle_request(result.assistant_text, CONTROL_TERMS)
|
||||
|
||||
assert result.commands[0].parameters["duration_ms"] == 2_000
|
||||
assert result.execution_eligible is False
|
||||
assert authorization.instruction is None
|
||||
assert authorization.reason == "denied_by_user"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"preview_request",
|
||||
(
|
||||
"先不要下发,给出解析的控制指令",
|
||||
"只解析不要执行",
|
||||
"仅预览",
|
||||
"不要执行",
|
||||
"不下发",
|
||||
"不要发送到车辆",
|
||||
),
|
||||
)
|
||||
def test_spoken_preview_routes_to_answer_only_and_never_calls_control_service(
|
||||
speech_processor: UnderstandingTranscriptProcessor,
|
||||
preview_request: str,
|
||||
) -> None:
|
||||
transcript = speech_processor.process(
|
||||
f"主驾座椅靠背向后移动2秒,{preview_request}"
|
||||
)
|
||||
client = FakeCodexClient()
|
||||
assistant = CodexAssistantService(client)
|
||||
events: list[dict[str, object]] = []
|
||||
control_calls: list[str] = []
|
||||
|
||||
def unexpected_control(
|
||||
instruction: str,
|
||||
_progress: Callable[[dict[str, object]], None],
|
||||
) -> dict[str, object]:
|
||||
control_calls.append(instruction)
|
||||
pytest.fail("a preview-only speech request reached vehicle execution")
|
||||
|
||||
result = assistant.stream(
|
||||
transcript.assistant_text,
|
||||
conversation_id="conv-speech-preview-answer-only",
|
||||
principal="speech-preview-matrix",
|
||||
history=[],
|
||||
emit=lambda event: events.append(event) or True,
|
||||
execute_control=unexpected_control,
|
||||
control_terms=CONTROL_TERMS,
|
||||
)
|
||||
|
||||
assert result["route"] == "agent"
|
||||
assert control_calls == []
|
||||
assert not any(str(event.get("type", "")).startswith("action.") for event in events)
|
||||
assert events[0]["route"] == "agent"
|
||||
assert events[-1]["type"] == "answer.completed"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "duration_ms"),
|
||||
(
|
||||
("主驾座椅靠背向后移动2s", 2_000),
|
||||
("主驾座椅靠背向后移动2秒", 2_000),
|
||||
("主驾椅背向后移动二秒", 2_000),
|
||||
("主驾椅背向后移动1.5s", 1_500),
|
||||
),
|
||||
)
|
||||
def test_ordinary_timed_speech_reaches_only_the_dry_run_service_bridge(
|
||||
tmp_path: Path,
|
||||
speech_processor: UnderstandingTranscriptProcessor,
|
||||
raw: str,
|
||||
duration_ms: int,
|
||||
) -> None:
|
||||
transcript = speech_processor.process(raw)
|
||||
understanding = transcript.to_dict()
|
||||
understanding["utterance_id"] = "timed-speech-preview-matrix"
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
service.artifact_root = tmp_path / "artifacts"
|
||||
|
||||
assert transcript.execution_eligible is True
|
||||
assert transcript.commands[0].parameters["duration_ms"] == duration_ms
|
||||
with patch.object(
|
||||
NativeCanExecutor,
|
||||
"execute",
|
||||
side_effect=AssertionError("dry-run speech reached the physical CAN executor"),
|
||||
) as physical_execute:
|
||||
result = service.process(
|
||||
transcript.clean_text,
|
||||
provider="local",
|
||||
conversation_id="conv-timed-speech-preview-matrix",
|
||||
execution_mode="dry_run",
|
||||
vehicle_id="denza_n7",
|
||||
_speech_understanding=understanding,
|
||||
)
|
||||
|
||||
assert physical_execute.call_count == 0
|
||||
assert result["status"] == "succeeded"
|
||||
assert result["execution_mode"] == "dry_run"
|
||||
assert result["hardware_execution_allowed"] is False
|
||||
assert result["provider_used"] == "speech_semantic"
|
||||
actions = result["intent_ir"]["spec"]["actions"]
|
||||
assert len(actions) == 1
|
||||
assert actions[0]["capability"] == "seat.set_driver_axis_native"
|
||||
assert actions[0]["parameters"] == {
|
||||
"axis": "backrest",
|
||||
"direction": "backward",
|
||||
"duration_ms": duration_ms,
|
||||
}
|
||||
command = result["command_plans"]["native_seat"]["spec"]["commands"][0]
|
||||
special_parameters = json.loads(command["signals"]["special_parameters"])
|
||||
assert special_parameters["duration_ms"] == duration_ms
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw",
|
||||
(
|
||||
"不要让主驾座椅靠背向后移动2秒,告诉我这样有什么影响",
|
||||
"如果主驾座椅靠背向后移动2秒会怎样",
|
||||
"请解释‘主驾座椅靠背向后移动2秒’是什么意思",
|
||||
"我们聊聊主驾座椅靠背向后移动2秒,但不要执行",
|
||||
),
|
||||
)
|
||||
def test_negation_hypothetical_and_chat_boundaries_never_become_executable(
|
||||
speech_processor: UnderstandingTranscriptProcessor,
|
||||
raw: str,
|
||||
) -> None:
|
||||
transcript = speech_processor.process(raw)
|
||||
authorization = _authorize_vehicle_request(transcript.assistant_text, CONTROL_TERMS)
|
||||
|
||||
assert transcript.execution_eligible is False
|
||||
assert authorization.instruction is None
|
||||
@@ -0,0 +1,427 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from collections.abc import Callable, Mapping
|
||||
from http.server import ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any, Self
|
||||
|
||||
from cognitive_agent.application.assistant_capabilities import AssistantCapabilityCatalog
|
||||
from cognitive_agent.application.codex_assistant import ActiveCodexTurn, CodexAssistantService
|
||||
from cognitive_agent.application.seat_service import SeatDemoService
|
||||
from cognitive_agent.auth import AuthConfig, AuthRuntime
|
||||
from cognitive_agent.server import SeatDemoHandler, VehicleDisplaySync
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
ORIGINAL_INSTRUCTION = "打开遮阳帘"
|
||||
WRONG_REQUEST_TOKEN = "mcp-loopback-wrong-token-456"
|
||||
_EOF = object()
|
||||
|
||||
SHADE_PLAN: dict[str, object] = {
|
||||
"detected_domains": ["roof"],
|
||||
"requests": [
|
||||
{
|
||||
"domain": "roof",
|
||||
"capability": "roof.set_shade_motion",
|
||||
"target_scope": "vehicle",
|
||||
"operation": "set",
|
||||
"duration_ms": None,
|
||||
"parameters": [{"name": "motion", "value": "open"}],
|
||||
"reason": "用户明确要求打开遮阳帘。",
|
||||
}
|
||||
],
|
||||
"ambiguities": [],
|
||||
"decision_summary": ["打开遮阳帘。"],
|
||||
"confidence": 0.99,
|
||||
}
|
||||
|
||||
|
||||
class _StdioMcpClient:
|
||||
"""Drive the real MCP child process without platform-specific pipe polling."""
|
||||
|
||||
def __init__(self, callback_url: str, callback_token: str) -> None:
|
||||
environment = os.environ.copy()
|
||||
python_paths = [
|
||||
ROOT / "src",
|
||||
ROOT / "domains" / "_template" / "src",
|
||||
ROOT / "domains" / "ambient_light" / "src",
|
||||
ROOT / "domains" / "climate" / "src",
|
||||
ROOT / "domains" / "roof" / "src",
|
||||
ROOT / "domains" / "seat" / "src",
|
||||
]
|
||||
inherited_python_path = environment.get("PYTHONPATH", "").strip()
|
||||
environment["PYTHONPATH"] = os.pathsep.join(
|
||||
[
|
||||
*(str(path) for path in python_paths),
|
||||
*([inherited_python_path] if inherited_python_path else []),
|
||||
]
|
||||
)
|
||||
environment["COGNITIVE_AGENT_CODEX_VEHICLE_CALLBACK_URL"] = callback_url
|
||||
environment["COGNITIVE_AGENT_CODEX_VEHICLE_CALLBACK_TOKEN"] = callback_token
|
||||
environment["COGNITIVE_AGENT_CODEX_VEHICLE_CALLBACK_TIMEOUT_SECONDS"] = "5"
|
||||
# MCP is a UTF-8 JSON-lines protocol. Force the same pipe encoding on
|
||||
# Windows (whose inherited console encoding may otherwise be GBK).
|
||||
environment["PYTHONUTF8"] = "1"
|
||||
environment["PYTHONIOENCODING"] = "utf-8"
|
||||
|
||||
self._responses: queue.Queue[object] = queue.Queue()
|
||||
self._stderr: list[str] = []
|
||||
self._next_id = 0
|
||||
self.process = subprocess.Popen(
|
||||
[
|
||||
sys.executable,
|
||||
"-u",
|
||||
"-m",
|
||||
"cognitive_agent.infrastructure.codex.vehicle_mcp_server",
|
||||
],
|
||||
cwd=ROOT,
|
||||
env=environment,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="strict",
|
||||
bufsize=1,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
self._stdout_thread = threading.Thread(target=self._read_stdout, daemon=True)
|
||||
self._stderr_thread = threading.Thread(target=self._read_stderr, daemon=True)
|
||||
self._stdout_thread.start()
|
||||
self._stderr_thread.start()
|
||||
|
||||
def _read_stdout(self) -> None:
|
||||
assert self.process.stdout is not None
|
||||
try:
|
||||
for line in self.process.stdout:
|
||||
self._responses.put(line)
|
||||
finally:
|
||||
self._responses.put(_EOF)
|
||||
|
||||
def _read_stderr(self) -> None:
|
||||
assert self.process.stderr is not None
|
||||
self._stderr.extend(self.process.stderr)
|
||||
|
||||
def request(self, method: str, params: Mapping[str, object]) -> dict[str, Any]:
|
||||
self._next_id += 1
|
||||
request_id = self._next_id
|
||||
self._write(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"method": method,
|
||||
"params": dict(params),
|
||||
}
|
||||
)
|
||||
try:
|
||||
raw = self._responses.get(timeout=10)
|
||||
except queue.Empty as exc:
|
||||
raise AssertionError(
|
||||
f"MCP response timed out; stderr={''.join(self._stderr)!r}"
|
||||
) from exc
|
||||
if raw is _EOF:
|
||||
raise AssertionError(
|
||||
f"MCP process exited before responding; stderr={''.join(self._stderr)!r}"
|
||||
)
|
||||
response = json.loads(str(raw))
|
||||
assert response["jsonrpc"] == "2.0"
|
||||
assert response["id"] == request_id
|
||||
return response
|
||||
|
||||
def notify(self, method: str, params: Mapping[str, object]) -> None:
|
||||
self._write({"jsonrpc": "2.0", "method": method, "params": dict(params)})
|
||||
|
||||
def _write(self, message: Mapping[str, object]) -> None:
|
||||
assert self.process.stdin is not None
|
||||
assert self.process.poll() is None, f"MCP child exited: {''.join(self._stderr)}"
|
||||
self.process.stdin.write(
|
||||
json.dumps(message, ensure_ascii=False, separators=(",", ":")) + "\n"
|
||||
)
|
||||
self.process.stdin.flush()
|
||||
|
||||
def close(self) -> None:
|
||||
if self.process.stdin is not None and not self.process.stdin.closed:
|
||||
self.process.stdin.close()
|
||||
try:
|
||||
return_code = self.process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.process.terminate()
|
||||
try:
|
||||
return_code = self.process.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.process.kill()
|
||||
return_code = self.process.wait(timeout=3)
|
||||
self._stdout_thread.join(timeout=2)
|
||||
self._stderr_thread.join(timeout=2)
|
||||
if self.process.stdout is not None:
|
||||
self.process.stdout.close()
|
||||
if self.process.stderr is not None:
|
||||
self.process.stderr.close()
|
||||
assert return_code == 0, (
|
||||
f"MCP child exited with {return_code}; stderr={''.join(self._stderr)!r}"
|
||||
)
|
||||
|
||||
def __enter__(self) -> Self:
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args: object) -> None:
|
||||
self.close()
|
||||
|
||||
|
||||
def _complete_turn(turn: ActiveCodexTurn) -> None:
|
||||
item_id = f"answer-{turn.turn_id}"
|
||||
answer = "已完成遮阳帘模拟编排。"
|
||||
turn.events.put(
|
||||
{
|
||||
"method": "item/completed",
|
||||
"params": {
|
||||
"item": {
|
||||
"type": "agentMessage",
|
||||
"id": item_id,
|
||||
"text": answer,
|
||||
"phase": "final_answer",
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
turn.events.put(
|
||||
{
|
||||
"method": "turn/completed",
|
||||
"params": {
|
||||
"turn": {
|
||||
"id": turn.turn_id,
|
||||
"status": "completed",
|
||||
"items": [],
|
||||
"error": None,
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class _McpDrivingCodexClient:
|
||||
"""Minimal model-side driver; all vehicle work still crosses the real MCP process."""
|
||||
|
||||
available = True
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.rpc: _StdioMcpClient | None = None
|
||||
self.started_with: tuple[str, str] | None = None
|
||||
self.capability_response: dict[str, Any] | None = None
|
||||
self.wrong_token_response: dict[str, Any] | None = None
|
||||
self.execution_response: dict[str, Any] | None = None
|
||||
self.active_request_token = ""
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
return "ready"
|
||||
|
||||
def start(self, callback_url: str, callback_token: str) -> None:
|
||||
self.started_with = (callback_url, callback_token)
|
||||
|
||||
def close(self) -> None:
|
||||
return None
|
||||
|
||||
def start_thread(self, *, developer_instructions: str, ephemeral: bool = True) -> str:
|
||||
assert "get_n7web_capabilities" in developer_instructions
|
||||
assert "execute_cockpit_request" in developer_instructions
|
||||
assert ephemeral is True
|
||||
return "thread-real-mcp-loopback"
|
||||
|
||||
def start_turn(
|
||||
self,
|
||||
thread_id: str,
|
||||
text: str,
|
||||
request_token: str,
|
||||
*,
|
||||
application_context: Mapping[str, object] | None = None,
|
||||
) -> ActiveCodexTurn:
|
||||
assert self.rpc is not None
|
||||
assert thread_id == "thread-real-mcp-loopback"
|
||||
assert text == ORIGINAL_INSTRUCTION
|
||||
assert request_token != WRONG_REQUEST_TOKEN
|
||||
assert len(request_token) >= 8
|
||||
assert application_context
|
||||
self.active_request_token = request_token
|
||||
turn = ActiveCodexTurn(
|
||||
thread_id=thread_id,
|
||||
turn_id="turn-real-mcp-loopback",
|
||||
events=queue.Queue(),
|
||||
)
|
||||
|
||||
self.capability_response = self.rpc.request(
|
||||
"tools/call",
|
||||
{
|
||||
"name": "get_n7web_capabilities",
|
||||
"arguments": {"request_token": request_token, "query": "遮阳帘"},
|
||||
},
|
||||
)
|
||||
self.wrong_token_response = self.rpc.request(
|
||||
"tools/call",
|
||||
{
|
||||
"name": "execute_cockpit_request",
|
||||
"arguments": {
|
||||
"request_token": WRONG_REQUEST_TOKEN,
|
||||
"plan": SHADE_PLAN,
|
||||
},
|
||||
},
|
||||
)
|
||||
self.execution_response = self.rpc.request(
|
||||
"tools/call",
|
||||
{
|
||||
"name": "execute_cockpit_request",
|
||||
# No instruction crosses MCP. The application must recover the
|
||||
# exact original text from this turn-bound opaque token.
|
||||
"arguments": {"request_token": request_token, "plan": SHADE_PLAN},
|
||||
},
|
||||
)
|
||||
_complete_turn(turn)
|
||||
return turn
|
||||
|
||||
def finish_turn(self, _turn: ActiveCodexTurn) -> None:
|
||||
return None
|
||||
|
||||
def interrupt(self, _turn: ActiveCodexTurn) -> None:
|
||||
return None
|
||||
|
||||
def unsubscribe_thread(self, _thread_id: str) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _auth_disabled() -> AuthRuntime:
|
||||
return AuthRuntime(AuthConfig(False, "", "", b"", 3600, 5, 300, 120, False))
|
||||
|
||||
|
||||
def test_real_mcp_stdio_process_reaches_bound_loopback_dry_run(tmp_path: Path) -> None:
|
||||
service = SeatDemoService(ROOT)
|
||||
service.artifact_root = tmp_path / "runs"
|
||||
catalog = AssistantCapabilityCatalog(
|
||||
service,
|
||||
peripheral_provider=list,
|
||||
nfc_provider=lambda: {"reader": {"state": "ready"}, "card": None},
|
||||
)
|
||||
client = _McpDrivingCodexClient()
|
||||
assistant = CodexAssistantService(client)
|
||||
handler = type("RealMcpLoopbackHandler", (SeatDemoHandler,), {})
|
||||
handler.service = service
|
||||
handler.assistant = assistant
|
||||
handler.auth = _auth_disabled()
|
||||
handler.vehicle_display_sync = VehicleDisplaySync()
|
||||
handler.assistant_capability_catalog = catalog
|
||||
handler.log_message = lambda *_args: None
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||
server_thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
server_thread.start()
|
||||
callback_url = (
|
||||
f"http://127.0.0.1:{server.server_port}/api/internal/codex/vehicle-control"
|
||||
)
|
||||
raw_results: list[dict[str, object]] = []
|
||||
observed_instructions: list[str] = []
|
||||
|
||||
def execute_control(
|
||||
instruction: str,
|
||||
progress: Callable[[dict[str, object]], None],
|
||||
assistant_plan: Mapping[str, object],
|
||||
) -> dict[str, object]:
|
||||
observed_instructions.append(instruction)
|
||||
result = service.preview_native_can(
|
||||
instruction,
|
||||
provider="local",
|
||||
conversation_id="conv-real-mcp-vehicle",
|
||||
execution_mode="dry_run",
|
||||
on_commands_validated=progress,
|
||||
assistant_plan=dict(assistant_plan),
|
||||
)
|
||||
raw_results.append(result)
|
||||
return result
|
||||
|
||||
try:
|
||||
assistant.start(callback_url)
|
||||
assert client.started_with == (callback_url, assistant.internal_token)
|
||||
with _StdioMcpClient(callback_url, assistant.internal_token) as rpc:
|
||||
client.rpc = rpc
|
||||
initialized = rpc.request(
|
||||
"initialize",
|
||||
{
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "n7web-integration-test", "version": "1.0"},
|
||||
},
|
||||
)
|
||||
assert initialized["result"]["protocolVersion"] == "2025-06-18"
|
||||
assert initialized["result"]["serverInfo"]["name"] == "n7web-cockpit"
|
||||
rpc.notify("notifications/initialized", {})
|
||||
|
||||
listed = rpc.request("tools/list", {})
|
||||
tools = listed["result"]["tools"]
|
||||
assert [tool["name"] for tool in tools] == [
|
||||
"get_n7web_capabilities",
|
||||
"execute_cockpit_request",
|
||||
]
|
||||
execute_schema = tools[1]["inputSchema"]["properties"]
|
||||
assert set(execute_schema) == {"request_token", "plan"}
|
||||
assert "instruction" not in execute_schema
|
||||
|
||||
snapshot = catalog.snapshot()
|
||||
result = assistant.stream(
|
||||
ORIGINAL_INSTRUCTION,
|
||||
conversation_id="conv-real-mcp-assistant",
|
||||
principal="real-mcp-integration-test",
|
||||
history=[],
|
||||
emit=lambda _event: True,
|
||||
execute_control=execute_control,
|
||||
capability_snapshot=snapshot,
|
||||
capability_context=catalog.compact_context(snapshot),
|
||||
control_terms=catalog.control_terms(snapshot),
|
||||
query_capabilities=catalog.query,
|
||||
)
|
||||
|
||||
assert result["route"] == "agent"
|
||||
assert client.capability_response is not None
|
||||
capability_result = client.capability_response["result"]
|
||||
assert capability_result["isError"] is False
|
||||
capability_catalog = capability_result["structuredContent"]["catalog"]
|
||||
assert capability_catalog["matched"] is True
|
||||
assert "roof.set_shade_motion" in {
|
||||
item["id"] for item in capability_catalog["capabilities"]
|
||||
}
|
||||
|
||||
assert client.wrong_token_response is not None
|
||||
rejected = client.wrong_token_response["result"]
|
||||
assert rejected["isError"] is True
|
||||
assert rejected["structuredContent"]["error"]["code"] == "n7web_tool_failed"
|
||||
assert "HTTP 409" in rejected["structuredContent"]["error"]["message"]
|
||||
|
||||
assert client.execution_response is not None
|
||||
execution = client.execution_response["result"]
|
||||
assert execution["isError"] is False
|
||||
structured = execution["structuredContent"]
|
||||
assert structured["status"] == "succeeded"
|
||||
assert structured["execution_mode"] == "dry_run"
|
||||
assert structured["verified"] is False
|
||||
assert structured["verification_status"] == "simulated"
|
||||
assert structured["utterance"] == ORIGINAL_INSTRUCTION
|
||||
assert {action["capability"] for action in structured["actions"]} == {
|
||||
"roof.set_shade_motion"
|
||||
}
|
||||
assert json.loads(execution["content"][0]["text"]) == structured
|
||||
|
||||
assert observed_instructions == [ORIGINAL_INSTRUCTION]
|
||||
assert len(raw_results) == 1
|
||||
assert raw_results[0]["utterance"] == ORIGINAL_INSTRUCTION
|
||||
assert raw_results[0]["execution_mode"] == "dry_run"
|
||||
assert raw_results[0]["status"] == "succeeded"
|
||||
assert rpc.process.poll() == 0
|
||||
finally:
|
||||
assistant.close()
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
server_thread.join(timeout=3)
|
||||
service.close()
|
||||
|
||||
assert not server_thread.is_alive()
|
||||
Reference in New Issue
Block a user