feat: add seat-scoped static and dynamic assistant modes
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
"""Full browser -> deterministic assistant -> schedule trial/save, without hardware."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
from http import HTTPStatus
|
||||
from http.server import ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
|
||||
from cognitive_agent.application.codex_assistant import CodexAssistantService
|
||||
from cognitive_agent.application.seat_service import SeatDemoService
|
||||
from cognitive_agent.auth import AuthConfig, AuthRuntime
|
||||
from cognitive_agent.server import REPOSITORY_ROOT, SeatDemoHandler
|
||||
|
||||
|
||||
def test_mode_schedule_real_browser_preview_trial_and_save(tmp_path: Path) -> None:
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
runtime = Path.home() / ".cache" / "codex-runtimes" / "codex-primary-runtime" / "dependencies" / "node" / "bin"
|
||||
node = next((str(path) for path in (runtime / "node.exe", runtime / "node") if path.is_file()), None)
|
||||
if not node:
|
||||
pytest.skip("Node.js is required for the schedule browser regression")
|
||||
env = dict(os.environ)
|
||||
modules = Path(node).resolve().parent.parent / "node_modules"
|
||||
if modules.is_dir():
|
||||
env["NODE_PATH"] = os.pathsep.join(value for value in (str(modules), env.get("NODE_PATH")) if value)
|
||||
probe = subprocess.run([node, "-e", "require.resolve('playwright')"], env=env, capture_output=True, encoding="utf-8", check=False)
|
||||
if probe.returncode:
|
||||
pytest.skip("Playwright is required for the schedule browser regression")
|
||||
|
||||
with patch.dict(os.environ, {
|
||||
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false",
|
||||
"COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED": "false",
|
||||
"COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED": "false",
|
||||
"COGNITIVE_AGENT_EXECUTION_MODE": "dry_run",
|
||||
"COGNITIVE_AGENT_MODE_PROFILE_ROOT": str(tmp_path / "user-modes"),
|
||||
}):
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
service.artifact_root = tmp_path / "artifacts"
|
||||
# A failed deterministic route must fail visibly, never launch a model or
|
||||
# make network calls to an external assistant during the browser test.
|
||||
client = MagicMock()
|
||||
client.available = False
|
||||
client.status = "test-offline"
|
||||
assistant = CodexAssistantService(client, fast_implicit_controls=True)
|
||||
forbidden = []
|
||||
|
||||
class BrowserHandler(SeatDemoHandler):
|
||||
auth = AuthRuntime(AuthConfig(False, "", "", b"", 3600, 5, 300, 120, False))
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
path = urlparse(self.path).path
|
||||
if path in {"/api/hardware/seat-state", "/api/hardware/vehicle-state"}:
|
||||
# Both legacy GET paths can touch physical adapters or firmware.
|
||||
self._json({"status": "unavailable", "hardware_execution_allowed": False})
|
||||
return
|
||||
if path == "/api/health":
|
||||
self._json({"status": "ok", "assistant": {"status": "ready", "available": True}})
|
||||
return
|
||||
super().do_GET()
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
path = urlparse(self.path).path
|
||||
if path == "/api/extension/display-sync":
|
||||
self._json({"status": "ok"})
|
||||
return
|
||||
if path not in {"/api/assistant/stream", "/api/mode-profile-drafts/trial", "/api/mode-profiles"}:
|
||||
forbidden.append(path)
|
||||
self._json({"error": "browser_test_write_forbidden"}, HTTPStatus.FORBIDDEN)
|
||||
return
|
||||
super().do_POST()
|
||||
|
||||
def _read_json_body(self, **kwargs):
|
||||
payload = super()._read_json_body(**kwargs)
|
||||
if payload.get("execution_mode", "dry_run") != "dry_run":
|
||||
forbidden.append(f"execution_mode:{payload.get('execution_mode')}")
|
||||
raise ValueError("Browser regression permits dry_run only")
|
||||
return payload
|
||||
|
||||
def log_message(self, *_args: object) -> None:
|
||||
pass
|
||||
|
||||
BrowserHandler.service = service
|
||||
BrowserHandler.assistant = assistant
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), BrowserHandler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
completed = subprocess.run([
|
||||
node, str(Path(__file__).with_name("mode_schedule_browser.cjs")),
|
||||
f"http://127.0.0.1:{server.server_port}", str(tmp_path),
|
||||
], env=env, capture_output=True, encoding="utf-8", timeout=180, check=False)
|
||||
assert completed.returncode == 0, completed.stdout + completed.stderr
|
||||
assert not forbidden, forbidden
|
||||
assert len(list((tmp_path / "user-modes").rglob("*.yaml"))) == 2
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=3)
|
||||
assistant.close()
|
||||
service.close()
|
||||
Reference in New Issue
Block a user