feat: 完善 N7 智能座舱交互与控制链路
This commit is contained in:
@@ -0,0 +1,496 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
from collections.abc import Callable, Mapping
|
||||
|
||||
import pytest
|
||||
|
||||
from cognitive_agent.application.codex_assistant import (
|
||||
ActiveCodexTurn,
|
||||
CodexAssistantService,
|
||||
)
|
||||
from cognitive_agent.application.seat_service import SeatDemoService
|
||||
from cognitive_agent.infrastructure.codex import vehicle_mcp_server
|
||||
|
||||
|
||||
def _complete_turn(turn: ActiveCodexTurn, answer: str = "已安全处理。") -> None:
|
||||
item_id = f"answer-{turn.turn_id}"
|
||||
turn.events.put(
|
||||
{
|
||||
"method": "item/started",
|
||||
"params": {
|
||||
"item": {
|
||||
"id": item_id,
|
||||
"type": "agentMessage",
|
||||
"phase": "final_answer",
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
turn.events.put(
|
||||
{
|
||||
"method": "item/agentMessage/delta",
|
||||
"params": {"itemId": item_id, "delta": answer},
|
||||
}
|
||||
)
|
||||
turn.events.put(
|
||||
{
|
||||
"method": "item/completed",
|
||||
"params": {
|
||||
"item": {
|
||||
"id": item_id,
|
||||
"type": "agentMessage",
|
||||
"phase": "final_answer",
|
||||
"text": answer,
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
turn.events.put(
|
||||
{
|
||||
"method": "turn/completed",
|
||||
"params": {"turn": {"id": turn.turn_id, "status": "completed"}},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class _SecurityProbeClient:
|
||||
available = True
|
||||
status = "ready"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.on_turn: Callable[[str, ActiveCodexTurn], None] | None = None
|
||||
self.request_token = ""
|
||||
|
||||
def start(self, _callback_url: str, _callback_token: str) -> None:
|
||||
return None
|
||||
|
||||
def close(self) -> None:
|
||||
return None
|
||||
|
||||
def start_thread(
|
||||
self,
|
||||
*,
|
||||
developer_instructions: str,
|
||||
ephemeral: bool = True,
|
||||
) -> str:
|
||||
assert developer_instructions
|
||||
assert ephemeral is True
|
||||
return "thread-security-probe"
|
||||
|
||||
def start_turn(
|
||||
self,
|
||||
thread_id: str,
|
||||
text: str,
|
||||
request_token: str,
|
||||
*,
|
||||
application_context: Mapping[str, object] | None = None,
|
||||
) -> ActiveCodexTurn:
|
||||
del text, application_context
|
||||
self.request_token = request_token
|
||||
turn = ActiveCodexTurn(thread_id, "turn-security-probe", queue.Queue())
|
||||
if self.on_turn is not None:
|
||||
self.on_turn(request_token, turn)
|
||||
_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 _stream(
|
||||
assistant: CodexAssistantService,
|
||||
message: str,
|
||||
*,
|
||||
execute_control: Callable[..., dict[str, object]] | None = None,
|
||||
) -> tuple[dict[str, object], list[dict[str, object]]]:
|
||||
events: list[dict[str, object]] = []
|
||||
result = assistant.stream(
|
||||
message,
|
||||
conversation_id="conv-security-boundary",
|
||||
principal="security-probe",
|
||||
history=[],
|
||||
emit=lambda event: events.append(event),
|
||||
execute_control=execute_control
|
||||
or (lambda *_args: pytest.fail("unexpected control execution")),
|
||||
capability_snapshot={},
|
||||
capability_context={},
|
||||
control_terms=("空调",),
|
||||
query_capabilities=lambda _snapshot, _query: {},
|
||||
)
|
||||
return result, events
|
||||
|
||||
|
||||
def _plan(parameter_value: object) -> dict[str, object]:
|
||||
return {
|
||||
"detected_domains": ["roof"],
|
||||
"requests": [
|
||||
{
|
||||
"domain": "roof",
|
||||
"capability": "roof.set_transmittance",
|
||||
"target_scope": "vehicle",
|
||||
"operation": "set",
|
||||
"duration_ms": None,
|
||||
"parameters": [{"name": "transmittance_pct", "value": parameter_value}],
|
||||
"reason": "用户明确指定透光率。",
|
||||
}
|
||||
],
|
||||
"ambiguities": [],
|
||||
"decision_summary": ["设置天幕透光率。"],
|
||||
"confidence": 0.99,
|
||||
}
|
||||
|
||||
|
||||
def _seat_motion_plan(
|
||||
capability: str,
|
||||
operation: str,
|
||||
parameters: Mapping[str, object],
|
||||
*,
|
||||
duration_ms: int | None = None,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"detected_domains": ["seat"],
|
||||
"requests": [
|
||||
{
|
||||
"domain": "seat",
|
||||
"capability": capability,
|
||||
"target_scope": "passenger",
|
||||
"operation": operation,
|
||||
"duration_ms": duration_ms,
|
||||
"parameters": [
|
||||
{"name": name, "value": value} for name, value in parameters.items()
|
||||
],
|
||||
"reason": "仅规范化用户明确指定的座椅运动。",
|
||||
}
|
||||
],
|
||||
"ambiguities": [],
|
||||
"decision_summary": ["执行用户明确指定的座椅运动。"],
|
||||
"confidence": 0.99,
|
||||
}
|
||||
|
||||
|
||||
def _prime_axis(
|
||||
service: SeatDemoService,
|
||||
conversation_id: str,
|
||||
axis: str,
|
||||
position_pct: int,
|
||||
) -> None:
|
||||
service.conversations.apply_successful_actions(
|
||||
conversation_id,
|
||||
"test setup",
|
||||
[
|
||||
{
|
||||
"action_type": "position",
|
||||
"target": "passenger",
|
||||
"axis": axis,
|
||||
"position_pct": position_pct,
|
||||
}
|
||||
],
|
||||
["test setup"],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("non_finite", [float("nan"), float("inf"), float("-inf")])
|
||||
def test_mcp_rejects_non_finite_plan_parameter_before_callback(
|
||||
non_finite: float,
|
||||
) -> None:
|
||||
callback_called = False
|
||||
|
||||
def callback(
|
||||
_tool: str,
|
||||
_arguments: Mapping[str, object],
|
||||
) -> Mapping[str, object]:
|
||||
nonlocal callback_called
|
||||
callback_called = True
|
||||
return {"status": "succeeded"}
|
||||
|
||||
response = vehicle_mcp_server.handle_message(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "execute_cockpit_request",
|
||||
"arguments": {
|
||||
"request_token": "request-token-123",
|
||||
"plan": _plan(non_finite),
|
||||
},
|
||||
},
|
||||
},
|
||||
callback,
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response["error"] == {
|
||||
"code": -32602,
|
||||
"message": "Invalid assistant plan",
|
||||
}
|
||||
assert callback_called is False
|
||||
|
||||
|
||||
def test_request_token_is_not_exposed_across_mcp_progress_events() -> None:
|
||||
client = _SecurityProbeClient()
|
||||
assistant = CodexAssistantService(client)
|
||||
|
||||
def expose_split_token(request_token: str, turn: ActiveCodexTurn) -> None:
|
||||
split = len(request_token) // 2
|
||||
for part in (request_token[:split], request_token[split:]):
|
||||
turn.events.put(
|
||||
{
|
||||
"method": "item/mcpToolCall/progress",
|
||||
"params": {"message": part},
|
||||
}
|
||||
)
|
||||
|
||||
client.on_turn = expose_split_token
|
||||
_result, events = _stream(assistant, "解释一下空调的工作原理")
|
||||
|
||||
serialized = json.dumps(events, ensure_ascii=False)
|
||||
split = len(client.request_token) // 2
|
||||
assert client.request_token[:split] not in serialized
|
||||
assert client.request_token[split:] not in serialized
|
||||
|
||||
|
||||
def test_model_controlled_summary_cannot_echo_request_token_to_browser() -> None:
|
||||
client = _SecurityProbeClient()
|
||||
assistant = CodexAssistantService(client)
|
||||
|
||||
def execute_tool(request_token: str, _turn: ActiveCodexTurn) -> None:
|
||||
assistant.execute_cockpit_request(request_token)
|
||||
|
||||
def execute_control(
|
||||
instruction: str,
|
||||
_progress: Callable[[dict[str, object]], None],
|
||||
) -> dict[str, object]:
|
||||
assert instruction == "打开空调"
|
||||
return {
|
||||
"status": "succeeded",
|
||||
"execution_mode": "dry_run",
|
||||
"decision_summary": [f"internal={client.request_token}"],
|
||||
"intent_ir": {"spec": {"actions": []}},
|
||||
}
|
||||
|
||||
client.on_turn = execute_tool
|
||||
_result, events = _stream(
|
||||
assistant,
|
||||
"打开空调",
|
||||
execute_control=execute_control,
|
||||
)
|
||||
|
||||
assert client.request_token not in json.dumps(events, ensure_ascii=False)
|
||||
|
||||
|
||||
def test_safe_vehicle_result_filters_sensitive_nested_action_parameters() -> None:
|
||||
safe = CodexAssistantService._safe_vehicle_result(
|
||||
{
|
||||
"status": "succeeded",
|
||||
"execution_mode": "dry_run",
|
||||
"intent_ir": {
|
||||
"spec": {
|
||||
"actions": [
|
||||
{
|
||||
"domain": "climate",
|
||||
"capability": "climate.set_power",
|
||||
"target": {"zone": "vehicle"},
|
||||
"parameters": {
|
||||
"state": "on",
|
||||
"raw_can": "41 01 02 03 04 05 06 07",
|
||||
"approval_token": "approval-secret",
|
||||
"internal_token": "internal-secret",
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert safe["actions"][0]["parameters"]["state"] == "on"
|
||||
serialized = json.dumps(safe, ensure_ascii=False)
|
||||
assert "raw_can" not in serialized
|
||||
assert "approval-secret" not in serialized
|
||||
assert "internal-secret" not in serialized
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"text",
|
||||
"capability",
|
||||
"axis",
|
||||
"correct_operation",
|
||||
"reversed_operation",
|
||||
"parameters",
|
||||
"duration_ms",
|
||||
"initial_position",
|
||||
),
|
||||
[
|
||||
(
|
||||
"副驾座椅向前移动20毫米",
|
||||
"seat.set_longitudinal_position",
|
||||
"longitudinal",
|
||||
"decrease",
|
||||
"increase",
|
||||
{"distance_mm": 20},
|
||||
None,
|
||||
50,
|
||||
),
|
||||
(
|
||||
"副驾座椅向后移动20毫米",
|
||||
"seat.set_longitudinal_position",
|
||||
"longitudinal",
|
||||
"increase",
|
||||
"decrease",
|
||||
{"distance_mm": 20},
|
||||
None,
|
||||
50,
|
||||
),
|
||||
(
|
||||
"把副驾坐垫前缘抬高2秒",
|
||||
"seat.set_cushion_tilt",
|
||||
"cushion_tilt",
|
||||
"decrease",
|
||||
"increase",
|
||||
{},
|
||||
2_000,
|
||||
50,
|
||||
),
|
||||
(
|
||||
"把副驾坐垫前缘降低2秒",
|
||||
"seat.set_cushion_tilt",
|
||||
"cushion_tilt",
|
||||
"increase",
|
||||
"decrease",
|
||||
{},
|
||||
2_000,
|
||||
50,
|
||||
),
|
||||
(
|
||||
"副驾靠背向后放倒10度",
|
||||
"seat.set_backrest_recline",
|
||||
"backrest_recline",
|
||||
"increase",
|
||||
"decrease",
|
||||
{"angle_delta_deg": 10},
|
||||
None,
|
||||
50,
|
||||
),
|
||||
(
|
||||
"副驾靠背向前调直10度",
|
||||
"seat.set_backrest_recline",
|
||||
"backrest_recline",
|
||||
"decrease",
|
||||
"increase",
|
||||
{"angle_delta_deg": 10},
|
||||
None,
|
||||
50,
|
||||
),
|
||||
(
|
||||
"请使副驾腿托角度向上调整10度",
|
||||
"seat.set_legrest_height",
|
||||
"legrest_height",
|
||||
"increase",
|
||||
"decrease",
|
||||
{"angle_delta_deg": 10},
|
||||
None,
|
||||
50,
|
||||
),
|
||||
(
|
||||
"请使副驾腿托角度向下调整10度",
|
||||
"seat.set_legrest_height",
|
||||
"legrest_height",
|
||||
"decrease",
|
||||
"increase",
|
||||
{"angle_delta_deg": 10},
|
||||
None,
|
||||
50,
|
||||
),
|
||||
(
|
||||
"请使副驾腿托伸长到65%",
|
||||
"seat.set_legrest_extension",
|
||||
"legrest_extension",
|
||||
"increase",
|
||||
"decrease",
|
||||
{"position_pct": 65},
|
||||
None,
|
||||
50,
|
||||
),
|
||||
(
|
||||
"请使副驾腿托缩回到35%",
|
||||
"seat.set_legrest_extension",
|
||||
"legrest_extension",
|
||||
"decrease",
|
||||
"increase",
|
||||
{"position_pct": 35},
|
||||
None,
|
||||
50,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_each_seat_axis_binds_physical_words_to_its_normalized_direction(
|
||||
tmp_path,
|
||||
text: str,
|
||||
capability: str,
|
||||
axis: str,
|
||||
correct_operation: str,
|
||||
reversed_operation: str,
|
||||
parameters: Mapping[str, object],
|
||||
duration_ms: int | None,
|
||||
initial_position: int,
|
||||
) -> None:
|
||||
service = SeatDemoService()
|
||||
service.artifact_root = tmp_path
|
||||
slug = capability.rsplit(".", 1)[-1].replace("_", "-")
|
||||
correct_conversation = f"conv-direction-correct-{slug}"
|
||||
reversed_conversation = f"conv-direction-reversed-{slug}"
|
||||
_prime_axis(service, correct_conversation, axis, initial_position)
|
||||
_prime_axis(service, reversed_conversation, axis, initial_position)
|
||||
|
||||
try:
|
||||
reversed_result = service.preview_native_can(
|
||||
text,
|
||||
provider="local",
|
||||
execution_mode="dry_run",
|
||||
conversation_id=reversed_conversation,
|
||||
assistant_plan=_seat_motion_plan(
|
||||
capability,
|
||||
reversed_operation,
|
||||
parameters,
|
||||
duration_ms=duration_ms,
|
||||
),
|
||||
)
|
||||
correct_result = service.preview_native_can(
|
||||
text,
|
||||
provider="local",
|
||||
execution_mode="dry_run",
|
||||
conversation_id=correct_conversation,
|
||||
assistant_plan=_seat_motion_plan(
|
||||
capability,
|
||||
correct_operation,
|
||||
parameters,
|
||||
duration_ms=duration_ms,
|
||||
),
|
||||
)
|
||||
finally:
|
||||
service.close()
|
||||
|
||||
assert reversed_result["status"] == "no_action"
|
||||
assert any("安全授权已过滤" in summary for summary in reversed_result["decision_summary"])
|
||||
assert correct_result["status"] == "succeeded"
|
||||
assert correct_result["provider_used"] == "local_rules+codex_app_server"
|
||||
actions = correct_result["intent_ir"]["spec"]["actions"]
|
||||
assert len(actions) == 1
|
||||
action = actions[0]
|
||||
assert action["capability"] == capability
|
||||
assert action["resolution"]["direction"] == correct_operation
|
||||
final_position = action["parameters"]["position_pct"]
|
||||
if correct_operation == "increase":
|
||||
assert final_position > initial_position
|
||||
else:
|
||||
assert final_position < initial_position
|
||||
Reference in New Issue
Block a user