428 lines
16 KiB
Python
428 lines
16 KiB
Python
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()
|