feat: add seat-scoped static and dynamic assistant modes
This commit is contained in:
@@ -0,0 +1,670 @@
|
||||
"""Dynamic scene creation through the real assistant, compiler and saved-mode APIs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
from copy import deepcopy
|
||||
from threading import Event
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from jsonschema import Draft202012Validator
|
||||
|
||||
from cognitive_agent.application.assistant_capabilities import AssistantCapabilityCatalog
|
||||
from cognitive_agent.application.codex_assistant import ActiveCodexTurn, CodexAssistantService
|
||||
from cognitive_agent.application.mode_profile import ModeProfileRegistry
|
||||
from cognitive_agent.application.mode_schedule import ModeSchedule
|
||||
from cognitive_agent.application.mode_schedule_service import public_schedule_status
|
||||
from cognitive_agent.application.scene_composer import (
|
||||
SceneCompositionError,
|
||||
build_scene_output_schema,
|
||||
normalize_scene_output,
|
||||
)
|
||||
from cognitive_agent.application.schedule_intent import needs_mode_schedule
|
||||
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
|
||||
from cognitive_agent.infrastructure.can.access import HardwareAccessRequiredError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def schedule_service(tmp_path, monkeypatch):
|
||||
for variable in (
|
||||
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED", "COGNITIVE_AGENT_VEHICLE_CAN_ENABLED",
|
||||
"COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED", "COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED",
|
||||
):
|
||||
monkeypatch.setenv(variable, "false")
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
service.artifact_root = tmp_path / "artifacts"
|
||||
user_root = tmp_path / "user-modes"
|
||||
service.mode_profiles = ModeProfileRegistry(REPOSITORY_ROOT / "config" / "modes", user_root=user_root)
|
||||
with (
|
||||
patch.object(service.hardware_execution, "reserve", side_effect=AssertionError("physical vehicle access forbidden")) as reserve,
|
||||
patch.object(service._vehicle_mqtt_transport, "bus", side_effect=AssertionError("physical MQTT access forbidden")),
|
||||
):
|
||||
try:
|
||||
yield service, AssistantCapabilityCatalog(service), user_root
|
||||
finally:
|
||||
service.close()
|
||||
reserve.assert_not_called()
|
||||
|
||||
|
||||
def assistant_preview(service, catalog, message, conversation="conv-schedule-flow", client=None):
|
||||
if client is None:
|
||||
client = MagicMock()
|
||||
client.available = False
|
||||
assistant = CodexAssistantService(client)
|
||||
snapshot = catalog.snapshot(vehicle_id="denza_n7")
|
||||
fast = catalog.fast_answer(message, snapshot)
|
||||
results, events, passed_plans = [], [], []
|
||||
|
||||
def execute(instruction, progress, assistant_plan=None):
|
||||
passed_plans.append(deepcopy(assistant_plan))
|
||||
result = service.preview_native_can(
|
||||
instruction, provider="local", conversation_id=conversation,
|
||||
execution_mode="hardware", vehicle_id="denza_n7",
|
||||
on_commands_validated=progress, assistant_plan=assistant_plan,
|
||||
)
|
||||
results.append(result)
|
||||
return result
|
||||
|
||||
response = assistant.stream(
|
||||
message, conversation_id=conversation, principal="schedule-test", history=[],
|
||||
authoritative_answer=fast.answer if fast else None,
|
||||
emit=lambda event: events.append(event) or True,
|
||||
execute_control=execute, capability_snapshot=snapshot,
|
||||
capability_context=catalog.compact_context(snapshot),
|
||||
scene_capability_context=catalog.scene_composition_context(snapshot),
|
||||
control_terms=catalog.control_terms(snapshot),
|
||||
)
|
||||
assert len(results) == 1, (response, results)
|
||||
return response, results[0], events, passed_plans, client
|
||||
|
||||
|
||||
def create_saved(service, catalog, message="构建一个上车预热的功能模式"):
|
||||
response, preview, _, _, _ = assistant_preview(service, catalog, message)
|
||||
assert response["success"] is True, (response, preview)
|
||||
draft = preview["mode_profile_draft"]
|
||||
saved = service.create_mode_profile_card(
|
||||
draft["name"], draft["instruction"], draft["source_request_id"], draft["vehicle_id"],
|
||||
)
|
||||
assert saved["status"] == "succeeded"
|
||||
return preview, saved["mode_profile"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("message", "template_id", "count", "repeats", "elapsed"), [
|
||||
("构建一个座椅的动态模式", "seat-dynamic", 4, 5, 300000),
|
||||
("构建一个上车预热的功能模式", "boarding-warmup", 7, 1, 420000),
|
||||
("帮我创建一个座椅动态模式吧", "seat-dynamic", 4, 5, 300000),
|
||||
("我想构建一个上车预热模式并预览", "boarding-warmup", 7, 1, 420000),
|
||||
("构建一个上车暖舱模式", "boarding-warmup", 7, 1, 420000),
|
||||
("构建一个空调预热模式", "boarding-warmup", 7, 1, 420000),
|
||||
("构建一个动态座椅模式", "seat-dynamic", 4, 5, 300000),
|
||||
("构建一个座椅交替放松模式", "seat-dynamic", 4, 5, 300000),
|
||||
])
|
||||
def test_user_temporal_requests_create_complete_multistage_template_without_model(
|
||||
schedule_service, message, template_id, count, repeats, elapsed,
|
||||
):
|
||||
service, catalog, user_root = schedule_service
|
||||
response, preview, events, plans, client = assistant_preview(service, catalog, message)
|
||||
assert response["success"] is True, (response, preview)
|
||||
assert response["route"] == "scene_template"
|
||||
assert preview["status"] == "succeeded"
|
||||
assert preview["execution_mode"] == "dry_run"
|
||||
assert preview["hardware_execution_allowed"] is False
|
||||
assert preview["orchestration_route"] == "mode_profile_create_preview"
|
||||
draft = preview["mode_profile_draft"]
|
||||
assert draft["source_template_id"] == template_id
|
||||
assert draft["generation_source"] == "scene_template_copy"
|
||||
assert draft["operation_count"] == count
|
||||
expected_schedule = deepcopy(plans[0]["schedule"])
|
||||
for phase in expected_schedule["phases"]:
|
||||
phase["operations"] = [
|
||||
op for op in phase["operations"]
|
||||
if op["domain"] != "seat" or op["target_scope"] == "passenger"
|
||||
]
|
||||
assert draft["schedule"] == preview["schedule"] == expected_schedule
|
||||
schedule = ModeSchedule.from_dict(preview["schedule"])
|
||||
assert schedule.repeat_count == repeats
|
||||
assert schedule.total_duration_ms == elapsed
|
||||
actions = preview["intent_ir"]["spec"]["actions"]
|
||||
assert len(actions) == count
|
||||
assert len({action["id"] for action in actions}) == count
|
||||
assert {item["phase_id"] for item in preview["schedule_phase_previews"]} == {phase.id for phase in schedule.phases}
|
||||
assert len(preview["schedule_phase_previews"]) == 2
|
||||
assert all(item["status"] == "succeeded" for item in preview["schedule_phase_previews"])
|
||||
assert all(any(key.startswith(phase.id + "-") for key in preview["command_plans"]) for phase in schedule.phases)
|
||||
assert {
|
||||
op["target_scope"] for phase in schedule.phases for op in phase.operations
|
||||
if op["domain"] == "seat"
|
||||
} == {"passenger"}
|
||||
assert events
|
||||
client.start_thread.assert_not_called()
|
||||
client.start_turn.assert_not_called()
|
||||
client.start_structured_turn.assert_not_called()
|
||||
assert not list(user_root.rglob("*.yaml"))
|
||||
assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("message", ["构建一个座椅的动态模式", "构建一个上车预热的功能模式"])
|
||||
def test_draft_trial_preserves_all_stages_repeats_and_virtual_time_without_thread(schedule_service, message):
|
||||
service, catalog, _ = schedule_service
|
||||
_, preview, _, _, _ = assistant_preview(service, catalog, message)
|
||||
draft = preview["mode_profile_draft"]
|
||||
with patch("cognitive_agent.application.mode_schedule.Thread", side_effect=AssertionError("dry-run spawned worker")):
|
||||
trial = service.trial_mode_profile_draft(
|
||||
draft["name"], draft["instruction"], draft["source_request_id"], draft["vehicle_id"],
|
||||
)
|
||||
assert trial["status"] == "succeeded"
|
||||
assert trial["hardware_execution_allowed"] is False
|
||||
assert trial["schedule"] == preview["schedule"]
|
||||
execution = trial["schedule_execution"]
|
||||
schedule = ModeSchedule.from_dict(preview["schedule"])
|
||||
assert execution["completed"] is True
|
||||
assert execution["virtual_elapsed_ms"] == schedule.total_duration_ms
|
||||
assert execution["executed_phases"] == len(schedule.phases) * schedule.repeat_count
|
||||
assert [step["phase_id"] for step in execution["phase_results"]] == [phase.id for phase in schedule.phases] * schedule.repeat_count
|
||||
assert len(trial["intent_ir"]["spec"]["actions"]) == schedule.operation_count
|
||||
|
||||
|
||||
@pytest.mark.parametrize("message", ["构建一个座椅的动态模式", "构建一个上车预热的功能模式"])
|
||||
def test_save_reload_and_direct_execute_keep_temporal_semantics(schedule_service, message):
|
||||
service, catalog, user_root = schedule_service
|
||||
preview, public_profile = create_saved(service, catalog, message)
|
||||
assert public_profile["schedule"] == preview["schedule"]
|
||||
assert public_profile["behavior"] == "cyclic"
|
||||
mode_id = public_profile["id"]
|
||||
service.mode_profiles = ModeProfileRegistry(REPOSITORY_ROOT / "config" / "modes", user_root=user_root)
|
||||
reloaded = service.mode_profiles.get_profile(mode_id, "denza_n7")
|
||||
assert reloaded.schedule.to_dict() == preview["schedule"]
|
||||
with patch("cognitive_agent.application.mode_schedule.Thread", side_effect=AssertionError("dry-run spawned worker")):
|
||||
executed = service.execute_mode_profile_card(
|
||||
mode_id, conversation_id="conv-schedule-execute", execution_mode="dry_run", vehicle_id="denza_n7",
|
||||
)
|
||||
assert executed["status"] == "succeeded", executed
|
||||
assert executed["orchestration_route"] == "mode_schedule_execute"
|
||||
assert executed["execution_mode"] == "dry_run"
|
||||
assert executed["hardware_execution_allowed"] is False
|
||||
assert "mode_profile_draft" not in executed
|
||||
execution = executed["schedule_execution"]
|
||||
assert execution["completed"] is True
|
||||
assert execution["virtual_elapsed_ms"] == reloaded.schedule.total_duration_ms
|
||||
assert len(execution["phase_results"]) == len(reloaded.schedule.phases) * reloaded.schedule.repeat_count
|
||||
expected_phases = list(reloaded.schedule.phases) * reloaded.schedule.repeat_count
|
||||
for expected, actual in zip(expected_phases, execution["phase_results"], strict=True):
|
||||
phase_result = actual["result"]
|
||||
assert phase_result["status"] == "succeeded"
|
||||
assert phase_result["execution_mode"] == "dry_run"
|
||||
actual_actions = phase_result["intent_ir"]["spec"]["actions"]
|
||||
assert len(actual_actions) == len(expected.operations)
|
||||
expected_params = sorted(json.dumps(op["parameters"], sort_keys=True) for op in expected.operations)
|
||||
actual_params = sorted(json.dumps(op["parameters"], sort_keys=True) for op in actual_actions)
|
||||
assert actual_params == expected_params
|
||||
assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle"
|
||||
|
||||
|
||||
def test_natural_language_enter_saved_schedule_runs_timeline_not_flat_actions(schedule_service):
|
||||
service, catalog, _ = schedule_service
|
||||
preview, saved = create_saved(service, catalog)
|
||||
result = service.process(
|
||||
f"进入{saved['name']}", provider="local", conversation_id="conv-schedule-natural-enter",
|
||||
execution_mode="dry_run", vehicle_id="denza_n7",
|
||||
)
|
||||
assert result["status"] == "succeeded", result
|
||||
assert result["orchestration_route"] == "mode_schedule_execute"
|
||||
assert result["schedule"] == preview["schedule"]
|
||||
temperatures = []
|
||||
for phase in result["schedule_execution"]["phase_results"]:
|
||||
temperatures.extend(action["parameters"]["temperature_c"] for action in phase["result"]["intent_ir"]["spec"]["actions"] if action["capability"] == "climate.set_temperature")
|
||||
assert temperatures == [28, 24]
|
||||
|
||||
|
||||
def test_missing_hardware_lease_rejects_before_any_worker_or_physical_access(schedule_service):
|
||||
service, catalog, _ = schedule_service
|
||||
_, saved = create_saved(service, catalog)
|
||||
with (
|
||||
patch("cognitive_agent.application.mode_schedule.Thread", side_effect=AssertionError("unauthorized worker started")),
|
||||
pytest.raises(HardwareAccessRequiredError),
|
||||
):
|
||||
service.execute_mode_profile_card(
|
||||
saved["id"], execution_mode="hardware", vehicle_id="denza_n7",
|
||||
conversation_id="conv-schedule-unauthorized", hardware_access_bypass=False,
|
||||
hardware_client_id="hwc-schedule-unauthorized",
|
||||
)
|
||||
assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("field", "value"), [
|
||||
("name", "其他模式"), ("instruction", "直接执行危险指令"),
|
||||
("vehicle_id", "other_vehicle"), ("source_request_id", "../../result"),
|
||||
])
|
||||
def test_client_cannot_rebind_schedule_draft_trial(schedule_service, field, value):
|
||||
service, catalog, user_root = schedule_service
|
||||
_, preview, _, _, _ = assistant_preview(service, catalog, "构建一个上车预热的功能模式")
|
||||
draft = preview["mode_profile_draft"]
|
||||
arguments = {key: draft[key] for key in ("name", "instruction", "source_request_id", "vehicle_id")}
|
||||
arguments[field] = value
|
||||
with pytest.raises(ValueError):
|
||||
service.trial_mode_profile_draft(**arguments)
|
||||
assert not list(user_root.rglob("*.yaml"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mutation", ["capability", "duration", "temperature", "target"])
|
||||
def test_tampered_template_schedule_never_creates_or_executes_draft(schedule_service, mutation):
|
||||
service, catalog, user_root = schedule_service
|
||||
template = next(item for item in catalog.snapshot(vehicle_id="denza_n7")["scene_templates"] if item["id"] == "boarding-warmup")
|
||||
plan = deepcopy(template["plan"])
|
||||
phase = plan["schedule"]["phases"][0]
|
||||
if mutation == "capability":
|
||||
phase["operations"][0]["capability"] = "door.set_vehicle_lock"
|
||||
elif mutation == "duration":
|
||||
phase["duration_ms"] = 1
|
||||
elif mutation == "temperature":
|
||||
phase["operations"][1]["parameters"]["temperature_c"] = 99
|
||||
else:
|
||||
phase["operations"][3]["target_scope"] = "passenger"
|
||||
try:
|
||||
result = service.preview_native_can(
|
||||
"构建一个上车预热的功能模式", provider="local", conversation_id="conv-schedule-tamper",
|
||||
execution_mode="hardware", vehicle_id="denza_n7", assistant_plan=plan,
|
||||
)
|
||||
except ValueError:
|
||||
result = None
|
||||
if result is not None:
|
||||
assert result["status"] != "succeeded"
|
||||
assert "mode_profile_draft" not in result
|
||||
assert result.get("hardware_execution_allowed") is not True
|
||||
assert not list(user_root.rglob("*.yaml"))
|
||||
assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle"
|
||||
|
||||
|
||||
def test_invalid_later_saved_phase_is_revalidated_before_any_phase_executes(schedule_service):
|
||||
service, catalog, _ = schedule_service
|
||||
_, saved = create_saved(service, catalog)
|
||||
profile = service.mode_profiles.get_profile(saved["id"], "denza_n7")
|
||||
# Represents a stale or externally edited user profile, not a trusted runtime
|
||||
# capability change. Its structure remains valid, but the target is unsafe.
|
||||
profile.schedule.phases[1].operations[0]["parameters"]["temperature_c"] = 99
|
||||
with patch.object(service.mode_schedule_runner, "simulate", side_effect=AssertionError("invalid later phase reached executor")):
|
||||
result = service.execute_mode_profile_card(
|
||||
saved["id"], conversation_id="conv-schedule-stale-capability",
|
||||
execution_mode="dry_run", vehicle_id="denza_n7",
|
||||
)
|
||||
assert result["status"] == "validation_failed", result
|
||||
assert result["mode_creation_failure"]["code"] == "MODE_SCHEDULE_PHASE_INVALID"
|
||||
assert "mode_profile_draft" not in result
|
||||
assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle"
|
||||
|
||||
|
||||
def test_execution_failure_after_validated_first_phase_prevents_later_repeats(schedule_service):
|
||||
service, catalog, _ = schedule_service
|
||||
_, saved = create_saved(service, catalog, "构建一个主驾和副驾的座椅动态模式")
|
||||
preview_method = service._preview_mode_schedule
|
||||
virtual_method = service.native_can_runtime.execute_virtual
|
||||
execution_started = False
|
||||
executed_massage_modes = []
|
||||
|
||||
def validate_then_arm(*args, **kwargs):
|
||||
nonlocal execution_started
|
||||
result = preview_method(*args, **kwargs)
|
||||
execution_started = True
|
||||
return result
|
||||
|
||||
def fault_on_second_phase(command_plan, function_config):
|
||||
if execution_started:
|
||||
for op in function_config["spec"]["operations"]:
|
||||
if op["capability"] == "seat.set_driver_massage_native":
|
||||
executed_massage_modes.append(op["parameters"]["mode"])
|
||||
if op["parameters"]["mode"] == "slack":
|
||||
raise RuntimeError("injected lower-level actuator failure")
|
||||
return virtual_method(command_plan, function_config)
|
||||
|
||||
with (
|
||||
patch.object(service, "_preview_mode_schedule", side_effect=validate_then_arm),
|
||||
patch.object(service.native_can_runtime, "execute_virtual", side_effect=fault_on_second_phase),
|
||||
):
|
||||
result = service.execute_mode_profile_card(
|
||||
saved["id"], conversation_id="conv-schedule-injected-failure",
|
||||
execution_mode="dry_run", vehicle_id="denza_n7",
|
||||
)
|
||||
execution = result["schedule_execution"]
|
||||
assert result["status"] == "failed", result
|
||||
assert execution["completed"] is False
|
||||
assert execution["error"]
|
||||
assert execution["executed_phases"] == 1
|
||||
assert execution["virtual_elapsed_ms"] == 30000
|
||||
assert execution["repeat_index"] == 0
|
||||
assert len(execution["phase_results"]) == 2
|
||||
assert executed_massage_modes == ["relax", "slack"]
|
||||
assert result["hardware_execution_allowed"] is False
|
||||
|
||||
|
||||
def structured_schedule():
|
||||
return {
|
||||
"requests": [], "ambiguities": [], "confidence": 0.96,
|
||||
"schedule": {
|
||||
"repeat_count": 2,
|
||||
"phases": [
|
||||
{"id": "warm", "name": "暖舱", "duration_ms": 30000,
|
||||
"requests": [{"capability": "climate.set_temperature", "target_scope": "vehicle", "parameters": {"temperature_c": 28}}]},
|
||||
{"id": "settle", "name": "恒温", "duration_ms": 60000,
|
||||
"requests": [{"capability": "climate.set_temperature", "target_scope": "vehicle", "parameters": {"temperature_c": 24}}]},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def structured_client(output):
|
||||
client = MagicMock()
|
||||
client.available = True
|
||||
client.status = "ready"
|
||||
client.start_thread.return_value = "thread-schedule-test"
|
||||
client.start_turn.side_effect = AssertionError("dynamic creation fell back to unstructured agent")
|
||||
|
||||
def start(thread_id, text, *, application_context, output_schema, timeout=None):
|
||||
Draft202012Validator(output_schema).validate(output)
|
||||
turn = ActiveCodexTurn(thread_id=thread_id, turn_id="turn-schedule-test", events=queue.Queue())
|
||||
turn.events.put({"method": "item/completed", "params": {"item": {"id": "answer", "type": "agentMessage", "text": json.dumps(output, ensure_ascii=False)}}})
|
||||
turn.events.put({"method": "turn/completed", "params": {"turn": {"id": turn.turn_id, "status": "completed", "items": [], "error": None}}})
|
||||
return turn
|
||||
|
||||
client.start_structured_turn.side_effect = start
|
||||
return client
|
||||
|
||||
|
||||
@pytest.mark.parametrize("message", [
|
||||
"构建一个星云动态模式,空调先28度持续30秒,然后24度持续60秒,循环2次",
|
||||
"构建一个阅读模式,空调先28度持续30秒,然后24度持续60秒,循环2次",
|
||||
"构建一个上车预热的功能模式,空调先28度持续30秒,然后24度持续60秒,循环2次",
|
||||
])
|
||||
def test_explicit_temporal_parameters_use_structured_composition_not_static_template(schedule_service, message):
|
||||
service, catalog, user_root = schedule_service
|
||||
output = structured_schedule()
|
||||
client = structured_client(output)
|
||||
response, preview, _, plans, _ = assistant_preview(service, catalog, message, client=client)
|
||||
assert response["route"] == "scene_composer", response
|
||||
assert response["success"] is True, (response, preview)
|
||||
assert preview["mode_profile_draft"]["generation_source"] != "scene_template_copy"
|
||||
client.start_structured_turn.assert_called_once()
|
||||
client.start_turn.assert_not_called()
|
||||
assert "schedule" in client.start_structured_turn.call_args.kwargs["output_schema"]["properties"]
|
||||
assert preview["schedule"] == plans[0]["schedule"]
|
||||
assert preview["schedule"]["repeat_count"] == 2
|
||||
assert [phase["duration_ms"] for phase in preview["schedule"]["phases"]] == [30000, 60000]
|
||||
assert [phase["operations"][0]["parameters"]["temperature_c"] for phase in preview["schedule"]["phases"]] == [28, 24]
|
||||
assert ModeSchedule.from_dict(preview["schedule"]).total_duration_ms == 180000
|
||||
assert not list(user_root.rglob("*.yaml"))
|
||||
|
||||
|
||||
def test_dynamic_schema_normalization_preserves_complete_phase_contract(schedule_service):
|
||||
_, catalog, _ = schedule_service
|
||||
context = catalog.scene_composition_context(catalog.snapshot(vehicle_id="denza_n7"))
|
||||
output = structured_schedule()
|
||||
schema = build_scene_output_schema(context, dynamic=True)
|
||||
Draft202012Validator(schema).validate(output)
|
||||
plan = normalize_scene_output(output, context, dynamic=True)
|
||||
assert plan["ambiguities"] == []
|
||||
schedule = ModeSchedule.from_dict(plan["schedule"])
|
||||
assert schedule.total_duration_ms == 180000
|
||||
assert schedule.operation_count == 2
|
||||
assert schedule.phases[0].operations[0]["domain"] == "climate"
|
||||
assert schedule.phases[1].operations[0]["parameters"] == {"temperature_c": 24}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mutation", [
|
||||
"static-root", "unknown-root", "unknown-schedule", "unknown-phase", "unknown-capability",
|
||||
"duplicate-phase", "duplicate-operation", "no-phases", "empty-phase", "bool-duration",
|
||||
"bool-repeat", "long-duration", "long-total", "nan-parameter", "motion-capability",
|
||||
])
|
||||
def test_invalid_structured_dynamic_plans_fail_closed_during_normalization(schedule_service, mutation):
|
||||
_, catalog, _ = schedule_service
|
||||
context = catalog.scene_composition_context(catalog.snapshot(vehicle_id="denza_n7"))
|
||||
output = structured_schedule()
|
||||
schedule = output["schedule"]
|
||||
phase = schedule["phases"][0]
|
||||
if mutation == "static-root":
|
||||
output["requests"] = deepcopy(phase["requests"])
|
||||
elif mutation == "unknown-root":
|
||||
output["execute_now"] = True
|
||||
elif mutation == "unknown-schedule":
|
||||
schedule["unbounded"] = True
|
||||
elif mutation == "unknown-phase":
|
||||
phase["skip_safety"] = True
|
||||
elif mutation == "unknown-capability":
|
||||
phase["requests"][0]["capability"] = "unknown.actuate"
|
||||
elif mutation == "motion-capability":
|
||||
phase["requests"][0] = {"capability": "seat.set_driver_axis_native", "target_scope": "driver", "parameters": {"duration_ms": 1000, "axis": "backrest", "direction": "backward"}}
|
||||
elif mutation == "duplicate-phase":
|
||||
schedule["phases"][1]["id"] = phase["id"]
|
||||
elif mutation == "duplicate-operation":
|
||||
phase["requests"].append(deepcopy(phase["requests"][0]))
|
||||
elif mutation == "no-phases":
|
||||
schedule["phases"] = []
|
||||
elif mutation == "empty-phase":
|
||||
phase["requests"] = []
|
||||
elif mutation == "bool-duration":
|
||||
phase["duration_ms"] = True
|
||||
elif mutation == "bool-repeat":
|
||||
schedule["repeat_count"] = True
|
||||
elif mutation == "long-duration":
|
||||
phase["duration_ms"] = 900001
|
||||
elif mutation == "long-total":
|
||||
schedule["repeat_count"] = 20
|
||||
phase["duration_ms"] = 900000
|
||||
elif mutation == "nan-parameter":
|
||||
phase["requests"][0]["parameters"]["temperature_c"] = float("nan")
|
||||
with pytest.raises(SceneCompositionError):
|
||||
normalize_scene_output(output, context, dynamic=True)
|
||||
|
||||
|
||||
def test_dynamic_ambiguity_produces_no_schedule_or_executable_actions(schedule_service):
|
||||
_, catalog, _ = schedule_service
|
||||
context = catalog.scene_composition_context(catalog.snapshot(vehicle_id="denza_n7"))
|
||||
output = {"requests": [], "ambiguities": ["当前未开放该动态动作,请指定其他设备。"], "confidence": 0.2, "schedule": None}
|
||||
Draft202012Validator(build_scene_output_schema(context, dynamic=True)).validate(output)
|
||||
plan = normalize_scene_output(output, context, dynamic=True)
|
||||
assert not plan["requests"]
|
||||
assert "schedule" not in plan
|
||||
assert plan["ambiguities"]
|
||||
|
||||
|
||||
def test_model_schedule_with_unresolved_requirement_never_generates_successful_draft(schedule_service):
|
||||
service, catalog, user_root = schedule_service
|
||||
context = catalog.scene_composition_context(catalog.snapshot(vehicle_id="denza_n7"))
|
||||
output = structured_schedule()
|
||||
output["ambiguities"] = ["不能完成要求的主驾座椅绝对位置控制。"]
|
||||
plan = normalize_scene_output(output, context, dynamic=True)
|
||||
assert "metadata" not in plan
|
||||
with pytest.raises(ValueError, match="未解决"):
|
||||
service.preview_native_can(
|
||||
"构建一个流光动态模式", provider="local", conversation_id="conv-schedule-ambiguous",
|
||||
execution_mode="hardware", vehicle_id="denza_n7", assistant_plan=plan,
|
||||
)
|
||||
assert not list(user_root.rglob("*.yaml"))
|
||||
assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("seat", "allowed_targets"), [
|
||||
("主驾", {"driver", "front_left"}), ("副驾", {"passenger", "front_right"}),
|
||||
])
|
||||
def test_single_seat_dynamic_execution_cannot_expand_to_both_seats(schedule_service, seat, allowed_targets):
|
||||
service, catalog, _ = schedule_service
|
||||
_, saved = create_saved(service, catalog, "构建一个主驾和副驾的座椅动态模式")
|
||||
result = service.process(
|
||||
f"只让{seat}进入{saved['name']}", provider="local", conversation_id="conv-schedule-single-seat",
|
||||
execution_mode="dry_run", vehicle_id="denza_n7",
|
||||
)
|
||||
if result["status"] == "succeeded":
|
||||
assert result["orchestration_route"] == "mode_schedule_execute"
|
||||
execution = result["schedule_execution"]
|
||||
for phase in execution["phase_results"]:
|
||||
actions = phase["result"]["intent_ir"]["spec"]["actions"]
|
||||
seat_targets = {action["target"].get("instance") or action["target"].get("zone") for action in actions if action["domain"] == "seat"}
|
||||
assert seat_targets and seat_targets <= allowed_targets
|
||||
else:
|
||||
assert result["status"] in {"validation_failed", "needs_clarification", "needs_confirmation", "failed"}
|
||||
assert not result.get("schedule_execution", {}).get("phase_results")
|
||||
assert result.get("hardware_execution_allowed") is not True
|
||||
|
||||
|
||||
def start_waiting_schedule(service):
|
||||
"""Run only a no-device test callback, holding until the tested stop request."""
|
||||
calls = []
|
||||
entered = Event()
|
||||
schedule = ModeSchedule.from_dict({
|
||||
"schema": "cognitive-agent-mode-schedule/v1", "repeat_count": 1,
|
||||
"phases": [
|
||||
{"id": phase_id, "name": phase_id, "duration_ms": 900000,
|
||||
"operations": [{"domain": "climate", "capability": "climate.set_temperature",
|
||||
"target_scope": "vehicle", "operation": "set",
|
||||
"parameters": {"temperature_c": temperature}, "reason": "test only"}]}
|
||||
for phase_id, temperature in (("first", 28), ("second", 24))
|
||||
],
|
||||
})
|
||||
|
||||
def execute(phase):
|
||||
calls.append(phase.id)
|
||||
entered.set()
|
||||
return {"status": "succeeded"}
|
||||
|
||||
service.mode_schedule_runner.start("denza_n7", schedule, execute, session_id="conv-stop-status")
|
||||
assert entered.wait(2)
|
||||
assert service.mode_schedule_runner.status("denza_n7")["status"] == "running"
|
||||
return calls
|
||||
|
||||
|
||||
def assistant_control(catalog, message, execute):
|
||||
client = MagicMock()
|
||||
client.available = False
|
||||
assistant = CodexAssistantService(client)
|
||||
snapshot = catalog.snapshot(vehicle_id="denza_n7")
|
||||
events = []
|
||||
response = assistant.stream(
|
||||
message, conversation_id="conv-stop-status", principal="schedule-status-test", history=[],
|
||||
emit=lambda event: events.append(event) or True,
|
||||
execute_control=execute, capability_snapshot=snapshot,
|
||||
capability_context=catalog.compact_context(snapshot),
|
||||
scene_capability_context=catalog.scene_composition_context(snapshot),
|
||||
control_terms=catalog.control_terms(snapshot),
|
||||
)
|
||||
client.start_thread.assert_not_called()
|
||||
client.start_structured_turn.assert_not_called()
|
||||
client.start_turn.assert_not_called()
|
||||
return response, events
|
||||
|
||||
|
||||
def test_dry_run_stop_does_not_cancel_a_running_vehicle_schedule(schedule_service):
|
||||
service, _, _ = schedule_service
|
||||
calls = start_waiting_schedule(service)
|
||||
result = service.process(
|
||||
"停止动态模式", provider="local", conversation_id="conv-stop-status",
|
||||
execution_mode="dry_run", vehicle_id="denza_n7",
|
||||
)
|
||||
assert result["status"] == "succeeded"
|
||||
assert result["schedule_execution"]["status"] == "simulated"
|
||||
assert result["hardware_execution_allowed"] is False
|
||||
assert service.mode_schedule_runner.status("denza_n7")["status"] == "running"
|
||||
assert calls == ["first"]
|
||||
persisted = json.loads((service.artifact_root / result["request_id"] / "result.json").read_text(encoding="utf-8"))
|
||||
assert persisted["orchestration_route"] == "mode_schedule_stop"
|
||||
assert persisted["trace_events"] == []
|
||||
|
||||
|
||||
def test_hardware_stop_cancels_future_stages_and_persists_the_result(schedule_service):
|
||||
service, _, _ = schedule_service
|
||||
calls = start_waiting_schedule(service)
|
||||
result = service.process(
|
||||
"停止动态模式", provider="local", conversation_id="conv-stop-status",
|
||||
execution_mode="hardware", vehicle_id="denza_n7",
|
||||
)
|
||||
assert result["status"] == "succeeded"
|
||||
assert result["schedule_execution"]["status"] == "cancelled"
|
||||
assert result["schedule_execution"]["completed"] is False
|
||||
assert service.mode_schedule_runner.status("denza_n7")["status"] == "cancelled"
|
||||
assert service.mode_schedule_runner.close(timeout=1)
|
||||
assert calls == ["first"]
|
||||
persisted = json.loads((service.artifact_root / result["request_id"] / "result.json").read_text(encoding="utf-8"))
|
||||
assert persisted["orchestration_route"] == "mode_schedule_stop"
|
||||
assert persisted["schedule_execution"]["status"] == "cancelled"
|
||||
assert persisted["trace_events"] == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("execution_mode", "action_state", "runner_state"), [
|
||||
("hardware", "cancelled", "cancelled"), ("dry_run", "simulated", "running"),
|
||||
])
|
||||
def test_assistant_stop_is_zero_model_success_and_preserves_execution_policy(
|
||||
schedule_service, execution_mode, action_state, runner_state,
|
||||
):
|
||||
service, catalog, _ = schedule_service
|
||||
calls = start_waiting_schedule(service)
|
||||
|
||||
def execute(message, progress):
|
||||
return service.process(
|
||||
message, provider="local", conversation_id="conv-stop-status",
|
||||
execution_mode=execution_mode, vehicle_id="denza_n7",
|
||||
)
|
||||
|
||||
response, events = assistant_control(catalog, "停止动态模式", execute)
|
||||
assert response["success"] is True, response
|
||||
assert response["route"] == "vehicle_control"
|
||||
action = next(event for event in events if event["type"] == "action.completed")
|
||||
assert action["success"] is True
|
||||
assert action["state"] == action_state
|
||||
assert action["payload"]["orchestration_route"] == "mode_schedule_stop"
|
||||
assert service.mode_schedule_runner.status("denza_n7")["status"] == runner_state
|
||||
assert calls == ["first"]
|
||||
assert "已完成" not in response["answer"]
|
||||
assert "未改变正在运行" in response["answer"] if execution_mode == "dry_run" else "已停止" in response["answer"]
|
||||
|
||||
|
||||
def test_assistant_running_schedule_is_accepted_without_claiming_all_phases_completed(schedule_service):
|
||||
service, catalog, _ = schedule_service
|
||||
preview, profile = create_saved(service, catalog)
|
||||
start_waiting_schedule(service)
|
||||
state = service.mode_schedule_runner.status("denza_n7")
|
||||
state["phase_results"] = [{"command_frames": ["sensitive-can-frame"]}]
|
||||
|
||||
def execute(message, progress):
|
||||
return {
|
||||
"status": "running", "execution_mode": "hardware", "hardware_execution_allowed": True,
|
||||
"orchestration_route": "mode_schedule_execute", "schedule_execution": state,
|
||||
"mode_profile": profile, "matched_modes": [profile], "schedule": preview["schedule"],
|
||||
}
|
||||
|
||||
response, events = assistant_control(catalog, f"进入{profile['name']}", execute)
|
||||
assert response["success"] is True, response
|
||||
action = next(event for event in events if event["type"] == "action.completed")
|
||||
assert action["success"] is True
|
||||
assert action["state"] == "running"
|
||||
assert action["payload"]["status"] == "running"
|
||||
assert action["payload"]["schedule_execution"]["completed"] is False
|
||||
assert "phase_results" not in action["payload"]["schedule_execution"]
|
||||
assert "sensitive-can-frame" not in json.dumps(events, ensure_ascii=False)
|
||||
assert "尚未完成所有阶段" in action["display_text"]
|
||||
assert "尚未完成所有阶段" in response["answer"]
|
||||
completed = next(event for event in events if event["type"] == "work.completed")
|
||||
assert completed["state"] == "running"
|
||||
assert "已完成" not in completed["display_text"]
|
||||
|
||||
|
||||
def test_public_schedule_status_exposes_only_lifecycle_and_redacts_raw_error():
|
||||
state = {
|
||||
"run_id": "run-public", "vehicle_id": "denza_n7", "status": "failed",
|
||||
"phase_index": 1, "repeat_index": 0, "completed": False,
|
||||
"error": "secret-token /private/path/driver.dll CAN payload 123456",
|
||||
"phase_results": [{"command_plan": {"data_hex": "deadbeef"}}],
|
||||
"raw_error": {"token": "secret-token"}, "command_plans": {"internal": "data"},
|
||||
"phase_count": {"nested": "unexpected"},
|
||||
}
|
||||
public = public_schedule_status(state)
|
||||
assert public["status"] == "failed"
|
||||
assert public["phase_index"] == 1
|
||||
assert public["completed"] is False
|
||||
assert public["error"] and public["error"] != state["error"]
|
||||
assert set(public) == {"run_id", "vehicle_id", "status", "phase_index", "repeat_index", "completed", "error"}
|
||||
for sensitive in ("secret-token", "driver.dll", "deadbeef", "123456"):
|
||||
assert sensitive not in json.dumps(public, ensure_ascii=False)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("message", ["外循环通风模式", "内循环模式"])
|
||||
def test_hvac_circulation_state_is_not_a_temporal_schedule(message):
|
||||
assert needs_mode_schedule(message) is False
|
||||
Reference in New Issue
Block a user