diff --git a/Server/src/transport/models.py b/Server/src/transport/models.py index 802951a24..3e06b6d63 100644 --- a/Server/src/transport/models.py +++ b/Server/src/transport/models.py @@ -62,6 +62,7 @@ class SessionDetails(BaseModel): hash: str unity_version: str connected_at: str + project_path: str | None = None class SessionList(BaseModel): diff --git a/Server/src/transport/plugin_hub.py b/Server/src/transport/plugin_hub.py index 1a71f30a7..9eebbe689 100644 --- a/Server/src/transport/plugin_hub.py +++ b/Server/src/transport/plugin_hub.py @@ -351,6 +351,7 @@ async def get_sessions(cls, user_id: str | None = None) -> SessionList: hash=session.project_hash, unity_version=session.unity_version, connected_at=session.connected_at.isoformat(), + project_path=session.project_path, ) for session_id, session in sessions.items() } diff --git a/Server/src/transport/unity_instance_middleware.py b/Server/src/transport/unity_instance_middleware.py index 9c367ef46..942895a0b 100644 --- a/Server/src/transport/unity_instance_middleware.py +++ b/Server/src/transport/unity_instance_middleware.py @@ -5,8 +5,11 @@ into the request-scoped state, allowing tools to access it via ctx.get_state("unity_instance"). """ from threading import RLock +import asyncio import logging +import os import time +from urllib.parse import unquote, urlparse from fastmcp.server.middleware import Middleware, MiddlewareContext @@ -47,6 +50,28 @@ def set_unity_instance_middleware(middleware: 'UnityInstanceMiddleware') -> None _unity_instance_middleware = middleware +def _file_uri_to_path(uri: str) -> str | None: + """Convert a ``file://`` URI to a local filesystem path (UNC- and drive-aware).""" + if not uri.startswith("file://"): + return None + parsed = urlparse(uri) + host = (parsed.netloc or "").strip() + path = unquote(parsed.path or "") + if host and host.lower() != "localhost": + path = f"//{host}{path}" # UNC: file://server/share/... -> //server/share/... + elif os.name == "nt" and len(path) >= 3 and path[0] == "/" and path[2] == ":": + path = path[1:] # drive-letter form /C:/... -> C:/... + return path or None + + +def _strip_assets(project_path: str) -> str: + """Return the Unity project root (stdio reports ``.../Assets``, HTTP the root).""" + normalized = project_path.replace("\\", "/").rstrip("/") + if normalized.lower().endswith("/assets"): + return normalized[: -len("/assets")] + return normalized + + class UnityInstanceMiddleware(Middleware): """ Middleware that manages per-session Unity instance selection. @@ -58,6 +83,8 @@ class UnityInstanceMiddleware(Middleware): def __init__(self): super().__init__() self._active_by_key: dict[str, str] = {} + self._root_dirs_by_key: dict[str, list[str]] = {} + self._root_dir_tasks_by_key: dict[str, asyncio.Task] = {} self._lock = RLock() self._metadata_lock = RLock() self._unity_managed_tool_names: set[str] = set() @@ -224,6 +251,110 @@ async def _resolve_instance_value(self, value: str, ctx) -> str: "Read mcpforunity://instances for current sessions." ) + async def _resolve_launch_dirs(self, ctx) -> list[str]: + """Discover the directories the client is working in, client-agnostic. + + The package's own ``UNITY_MCP_PROJECT_DIR`` override wins (a single dir); + otherwise the ``file://`` MCP roots the client advertises - the + protocol-native signal for what the session is working on, of which there + may be several. Empty when neither is available. + """ + explicit = os.environ.get("UNITY_MCP_PROJECT_DIR") + if explicit: + return [explicit] + return await self._client_root_dirs(ctx) + + async def _client_root_dirs(self, ctx) -> list[str]: + """The client's ``file://`` MCP roots, resolved once per session. + + Cached because each lookup is a round-trip to the client and a session's + working directory does not change underneath us; this keeps the no-match + path off the wire on every subsequent tool call. + """ + key = await self.get_session_key(ctx) + with self._lock: + cached = self._root_dirs_by_key.get(key) + if cached is not None: + return cached + # Memoize the in-flight probe so concurrent first calls share one + # round-trip instead of each issuing their own. + task = self._root_dir_tasks_by_key.get(key) + if task is None: + task = asyncio.create_task(self._fetch_client_root_dirs(ctx)) + self._root_dir_tasks_by_key[key] = task + dirs = await task + with self._lock: + self._root_dir_tasks_by_key.pop(key, None) + self._root_dirs_by_key[key] = dirs + return dirs + + @staticmethod + async def _fetch_client_root_dirs(ctx) -> list[str]: + """Query MCP roots and return their local paths (``file://`` only). + + The caller caches the result per session, so a client that does not + support roots costs at most one failed probe, not one per tool call. + """ + list_roots = getattr(ctx, "list_roots", None) + if not callable(list_roots): + return [] + try: + roots = await list_roots() + except Exception: + # Client does not implement roots, or the request failed; not fatal. + return [] + dirs: list[str] = [] + for root in roots or []: + path = _file_uri_to_path(str(getattr(root, "uri", "") or "")) + if path: + dirs.append(path) + return dirs + + @staticmethod + def _select_instance_by_launch_dir( + candidates: list[tuple[str | None, str | None]], + launch_dirs: list[str], + ) -> str | None: + """Pick the single connected editor whose project matches a launch dir. + + Each candidate's project path is normalized to the project root (Unity + reports the ``Assets`` folder over stdio but the project root over HTTP) + and matched by path lineage against every launch directory - the project + root contains, equals, or is contained by one of them. This routes + per-checkout and git-worktree setups (identical project names, different + paths) without an explicit ``unity_instance``. Returns the id only when + exactly one editor matches; otherwise None, so the caller keeps its + "ask the user to choose" behavior. + """ + launch_reals = [ + os.path.normcase(os.path.realpath(d)) for d in launch_dirs if d + ] + if not launch_reals: + return None + + matches: set[str] = set() + for inst_id, project_path in candidates: + if not inst_id: + continue + project_root = _strip_assets(project_path) if project_path else "" + if not project_root: + # A connected instance we cannot place (e.g. an older plugin that + # does not report project_path). Refuse to guess among the rest. + return None + project_real = os.path.normcase(os.path.realpath(project_root)) + for launch_real in launch_reals: + try: + shared = os.path.commonpath([launch_real, project_real]) + except ValueError: + continue # e.g. paths on different Windows drives + if shared in (launch_real, project_real): + matches.add(inst_id) + break + + if len(matches) == 1: + return next(iter(matches)) + return None + async def _maybe_autoselect_instance(self, ctx) -> str | None: """ Auto-select the sole Unity instance when no active instance is set. @@ -241,13 +372,17 @@ async def _maybe_autoselect_instance(self, ctx) -> str | None: try: sessions_data = await PluginHub.get_sessions() sessions = sessions_data.sessions or {} - ids: list[str] = [] + candidates: list[tuple[str | None, str | None]] = [] for session_info in sessions.values(): project = getattr( session_info, "project", None) or "Unknown" hash_value = getattr(session_info, "hash", None) if hash_value: - ids.append(f"{project}@{hash_value}") + candidates.append(( + f"{project}@{hash_value}", + getattr(session_info, "project_path", None), + )) + ids = [inst_id for inst_id, _ in candidates] if len(ids) == 1: chosen = ids[0] await self.set_active_instance(ctx, chosen) @@ -257,6 +392,17 @@ async def _maybe_autoselect_instance(self, ctx) -> str | None: ) return chosen if len(ids) > 1: + launch_dirs = await self._resolve_launch_dirs(ctx) + chosen = self._select_instance_by_launch_dir( + candidates, launch_dirs) + if chosen: + await self.set_active_instance(ctx, chosen) + logger.info( + "Auto-selected Unity instance %s via launch directory " + "(of %d running) over PluginHub.", + chosen, len(ids), + ) + return chosen logger.info( "Multiple Unity instances found (%d). Pass unity_instance on any tool call " "or call set_active_instance to choose one. Available: %s", @@ -284,8 +430,11 @@ async def _maybe_autoselect_instance(self, ctx) -> str | None: pool = get_unity_connection_pool() instances = pool.discover_all_instances(force_refresh=True) - ids = [getattr(inst, "id", None) for inst in instances] - ids = [inst_id for inst_id in ids if inst_id] + candidates = [ + (getattr(inst, "id", None), getattr(inst, "path", None)) + for inst in instances + ] + ids = [inst_id for inst_id, _ in candidates if inst_id] if len(ids) == 1: chosen = ids[0] await self.set_active_instance(ctx, chosen) @@ -295,6 +444,17 @@ async def _maybe_autoselect_instance(self, ctx) -> str | None: ) return chosen if len(ids) > 1: + launch_dirs = await self._resolve_launch_dirs(ctx) + chosen = self._select_instance_by_launch_dir( + candidates, launch_dirs) + if chosen: + await self.set_active_instance(ctx, chosen) + logger.info( + "Auto-selected Unity instance %s via launch directory " + "(of %d running) via stdio discovery.", + chosen, len(ids), + ) + return chosen logger.info( "Multiple Unity instances found (%d). Pass unity_instance on any tool call " "or call set_active_instance to choose one. Available: %s", diff --git a/Server/tests/integration/test_instance_autoselect_by_launch_dir.py b/Server/tests/integration/test_instance_autoselect_by_launch_dir.py new file mode 100644 index 000000000..17ab655b0 --- /dev/null +++ b/Server/tests/integration/test_instance_autoselect_by_launch_dir.py @@ -0,0 +1,378 @@ +"""Auto-selection of a Unity instance by the client's launch directory. + +When more than one editor is connected, the server resolves the directories the +client is working in - via UNITY_MCP_PROJECT_DIR or the file:// MCP roots the +client advertises - and picks the instance whose Unity project shares a path +lineage with one of them. This keeps per-checkout and git-worktree setups +(identical project names, different paths) routing without an explicit +unity_instance, and it is client-agnostic (roots is the protocol-native signal). +""" +import asyncio +import sys +import types +from types import SimpleNamespace + +import pytest + +from .test_helpers import DummyContext +from core.config import config + + +def _load_middleware(monkeypatch, *, plugin_hub_configured): + """Import the middleware against a stubbed PluginHub (mirrors sibling tests).""" + plugin_hub = types.ModuleType("transport.plugin_hub") + + class PluginHub: + @classmethod + def is_configured(cls) -> bool: + """Report whatever configured state the test requested.""" + return plugin_hub_configured + + @classmethod + async def get_sessions(cls, *args, **kwargs): + """Fail loudly unless a test overrides this stub.""" + raise AssertionError("get_sessions should be stubbed by the test") + + plugin_hub.PluginHub = PluginHub + monkeypatch.setitem(sys.modules, "transport.plugin_hub", plugin_hub) + monkeypatch.delitem( + sys.modules, "transport.unity_instance_middleware", raising=False) + + import transport.unity_instance_middleware as mod + return mod + + +class _FakeRootsContext: + """Minimal context exposing client_id and the MCP roots capability.""" + + def __init__(self, roots=None, fail=False, delay=0): + """Create a context with a stable client id and optional roots.""" + self.client_id = "client-roots" + self._roots = roots or [] + self._fail = fail + self._delay = delay + self.list_roots_calls = 0 + + async def list_roots(self): + """Return the configured roots, or raise to mimic a client without roots.""" + self.list_roots_calls += 1 + if self._delay: + await asyncio.sleep(self._delay) + if self._fail: + raise RuntimeError("client does not support roots") + return self._roots + + +def _root(uri): + """Wrap a URI string as a minimal MCP root object.""" + return SimpleNamespace(uri=uri) + + +# --- _file_uri_to_path: file:// parsing (UNC / localhost / percent-encoding) --- + +def test_file_uri_plain_posix(monkeypatch): + """A plain POSIX file:// URI maps to its path.""" + mod = _load_middleware(monkeypatch, plugin_hub_configured=False) + assert mod._file_uri_to_path("file:///work/repo-a") == "/work/repo-a" + + +def test_file_uri_localhost_host_dropped(monkeypatch): + """A localhost host in a file:// URI is dropped.""" + mod = _load_middleware(monkeypatch, plugin_hub_configured=False) + assert mod._file_uri_to_path("file://localhost/work/x") == "/work/x" + + +def test_file_uri_unc_keeps_host(monkeypatch): + """A non-localhost host becomes a UNC path.""" + mod = _load_middleware(monkeypatch, plugin_hub_configured=False) + assert mod._file_uri_to_path( + "file://server/share/proj") == "//server/share/proj" + + +def test_file_uri_percent_decoded(monkeypatch): + """Percent-encoded octets in the path are decoded.""" + mod = _load_middleware(monkeypatch, plugin_hub_configured=False) + assert mod._file_uri_to_path("file:///work/a%20b") == "/work/a b" + + +def test_file_uri_non_file_scheme_is_none(monkeypatch): + """A non-file:// URI yields None.""" + mod = _load_middleware(monkeypatch, plugin_hub_configured=False) + assert mod._file_uri_to_path("mcpforunity://path/Assets/x") is None + + +# --- _select_instance_by_launch_dir: path-lineage matching over many dirs --- + +def _select(mod, candidates, launch_dirs): + """Invoke the static launch-dir matcher under test.""" + return mod.UnityInstanceMiddleware._select_instance_by_launch_dir( + candidates, launch_dirs) + + +def test_select_picks_project_nested_under_launch_dir(monkeypatch): + """A project nested under the launch dir is selected over an unrelated one.""" + mod = _load_middleware(monkeypatch, plugin_hub_configured=False) + candidates = [ + ("RepoA@aaaa1111", "/work/repo-a/repo-a-unity/Assets"), + ("RepoB@bbbb2222", "/work/repo-b/repo-b-unity/Assets"), + ] + assert _select(mod, candidates, ["/work/repo-a"]) == "RepoA@aaaa1111" + + +def test_select_normalizes_http_root_form(monkeypatch): + """An HTTP-form project root (no /Assets) matches after normalization.""" + mod = _load_middleware(monkeypatch, plugin_hub_configured=False) + assert _select(mod, [("RepoA@aaaa1111", "/work/repo-a/repo-a-unity")], + ["/work/repo-a"]) == "RepoA@aaaa1111" + + +def test_select_matches_when_launch_dir_is_inside_project(monkeypatch): + """A launch dir inside the project (a subfolder) still resolves to it.""" + mod = _load_middleware(monkeypatch, plugin_hub_configured=False) + candidates = [ + ("RepoA@aaaa1111", "/work/repo-a/repo-a-unity/Assets"), + ("RepoB@bbbb2222", "/work/repo-b/repo-b-unity/Assets"), + ] + assert _select(mod, candidates, + ["/work/repo-a/repo-a-unity/Assets/Scripts"]) == "RepoA@aaaa1111" + + +def test_select_returns_none_when_multiple_projects_match(monkeypatch): + """Two projects under one launch dir are ambiguous, so nothing is selected.""" + mod = _load_middleware(monkeypatch, plugin_hub_configured=False) + candidates = [ + ("RepoA@aaaa1111", "/work/repo-a/repo-a-unity/Assets"), + ("RepoADup@cccc3333", "/work/repo-a/secondary-unity/Assets"), + ] + assert _select(mod, candidates, ["/work/repo-a"]) is None + + +def test_select_returns_none_when_no_project_in_lineage(monkeypatch): + """No project sharing the launch dir's lineage yields None.""" + mod = _load_middleware(monkeypatch, plugin_hub_configured=False) + candidates = [ + ("RepoA@aaaa1111", "/work/repo-a/repo-a-unity/Assets"), + ("RepoB@bbbb2222", "/work/repo-b/repo-b-unity/Assets"), + ] + assert _select(mod, candidates, ["/work/repo-z"]) is None + + +def test_select_returns_none_without_launch_dirs(monkeypatch): + """With no launch dirs there is no signal, so nothing is selected.""" + mod = _load_middleware(monkeypatch, plugin_hub_configured=False) + candidates = [("RepoA@aaaa1111", "/work/repo-a/repo-a-unity/Assets")] + assert _select(mod, candidates, []) is None + + +def test_select_multi_root_unique_match(monkeypatch): + """Across several advertised roots, the single matching editor is chosen.""" + mod = _load_middleware(monkeypatch, plugin_hub_configured=False) + candidates = [ + ("RepoA@aaaa1111", "/work/repo-a/repo-a-unity/Assets"), + ("RepoC@cccc3333", "/work/repo-c/repo-c-unity/Assets"), + ] + assert _select(mod, candidates, + ["/work/repo-a", "/work/repo-b"]) == "RepoA@aaaa1111" + + +def test_select_multi_root_matches_non_first_root(monkeypatch): + """An editor under a non-first root still matches (not just the first root).""" + mod = _load_middleware(monkeypatch, plugin_hub_configured=False) + candidates = [("RepoB@bbbb2222", "/work/repo-b/repo-b-unity/Assets")] + assert _select(mod, candidates, + ["/work/repo-a", "/work/repo-b"]) == "RepoB@bbbb2222" + + +def test_select_multi_root_ambiguous_returns_none(monkeypatch): + """Two roots that each match a different editor are ambiguous -> None.""" + mod = _load_middleware(monkeypatch, plugin_hub_configured=False) + candidates = [ + ("RepoA@aaaa1111", "/work/repo-a/repo-a-unity/Assets"), + ("RepoB@bbbb2222", "/work/repo-b/repo-b-unity/Assets"), + ] + assert _select(mod, candidates, ["/work/repo-a", "/work/repo-b"]) is None + + +def test_select_returns_none_when_a_candidate_has_no_path(monkeypatch): + """An unplaceable instance (no project_path) makes the whole set ambiguous.""" + mod = _load_middleware(monkeypatch, plugin_hub_configured=False) + candidates = [ + ("RepoA@aaaa1111", "/work/repo-a/repo-a-unity/Assets"), + ("Legacy@dddd4444", None), # older plugin: no project_path reported + ] + assert _select(mod, candidates, ["/work/repo-a"]) is None + + +# --- _resolve_launch_dirs: env override, roots, caching --- + +@pytest.mark.asyncio +async def test_resolve_prefers_explicit_env_over_roots(monkeypatch): + """UNITY_MCP_PROJECT_DIR wins over roots and skips the client round-trip.""" + mod = _load_middleware(monkeypatch, plugin_hub_configured=False) + monkeypatch.setenv("UNITY_MCP_PROJECT_DIR", "/work/explicit") + middleware = mod.UnityInstanceMiddleware() + ctx = _FakeRootsContext(roots=[_root("file:///work/from-roots")]) + assert await middleware._resolve_launch_dirs(ctx) == ["/work/explicit"] + assert ctx.list_roots_calls == 0 + + +@pytest.mark.asyncio +async def test_resolve_uses_all_mcp_roots_when_no_env(monkeypatch): + """Without the env override, all advertised file:// roots are returned.""" + mod = _load_middleware(monkeypatch, plugin_hub_configured=False) + monkeypatch.delenv("UNITY_MCP_PROJECT_DIR", raising=False) + middleware = mod.UnityInstanceMiddleware() + ctx = _FakeRootsContext(roots=[ + _root("file:///work/repo-a"), _root("file:///work/repo-b")]) + assert await middleware._resolve_launch_dirs(ctx) == [ + "/work/repo-a", "/work/repo-b"] + + +@pytest.mark.asyncio +async def test_resolve_caches_failed_roots_probe(monkeypatch): + """A client without roots is probed at most once; the failure is cached.""" + mod = _load_middleware(monkeypatch, plugin_hub_configured=False) + monkeypatch.delenv("UNITY_MCP_PROJECT_DIR", raising=False) + middleware = mod.UnityInstanceMiddleware() + ctx = _FakeRootsContext(fail=True) + assert await middleware._resolve_launch_dirs(ctx) == [] + assert await middleware._resolve_launch_dirs(ctx) == [] + assert ctx.list_roots_calls == 1 + + +@pytest.mark.asyncio +async def test_resolve_caches_roots_per_session(monkeypatch): + """Resolved roots are cached per session, so only one probe is issued.""" + mod = _load_middleware(monkeypatch, plugin_hub_configured=False) + monkeypatch.delenv("UNITY_MCP_PROJECT_DIR", raising=False) + middleware = mod.UnityInstanceMiddleware() + ctx = _FakeRootsContext(roots=[_root("file:///work/repo-a")]) + first = await middleware._resolve_launch_dirs(ctx) + second = await middleware._resolve_launch_dirs(ctx) + assert first == second == ["/work/repo-a"] + assert ctx.list_roots_calls == 1 + + +@pytest.mark.asyncio +async def test_resolve_roots_probed_once_under_concurrency(monkeypatch): + """Concurrent first calls share one in-flight probe, not one each.""" + mod = _load_middleware(monkeypatch, plugin_hub_configured=False) + monkeypatch.delenv("UNITY_MCP_PROJECT_DIR", raising=False) + middleware = mod.UnityInstanceMiddleware() + ctx = _FakeRootsContext(roots=[_root("file:///work/repo-a")], delay=0.02) + first, second = await asyncio.gather( + middleware._resolve_launch_dirs(ctx), + middleware._resolve_launch_dirs(ctx), + ) + assert first == second == ["/work/repo-a"] + assert ctx.list_roots_calls == 1 + + +# --- _maybe_autoselect_instance: end-to-end disambiguation --- + +def _stub_pool(monkeypatch, instances): + """Patch the stdio connection pool to return the given instances.""" + class PoolStub: + def discover_all_instances(self, force_refresh=False): + """Return the canned instances; force_refresh is asserted by the caller.""" + assert force_refresh is True + return instances + + unity_connection = types.ModuleType("transport.legacy.unity_connection") + unity_connection.get_unity_connection_pool = lambda: PoolStub() + monkeypatch.setitem( + sys.modules, "transport.legacy.unity_connection", unity_connection) + + +@pytest.mark.asyncio +async def test_autoselect_disambiguates_via_env(monkeypatch): + """End-to-end stdio selection driven by UNITY_MCP_PROJECT_DIR.""" + mod = _load_middleware(monkeypatch, plugin_hub_configured=False) + monkeypatch.setenv("UNITY_MCP_PROJECT_DIR", "/work/repo-a") + monkeypatch.setattr(config, "transport_mode", "stdio") + _stub_pool(monkeypatch, [ + SimpleNamespace(id="RepoA@aaaa1111", path="/work/repo-a/repo-a-unity/Assets"), + SimpleNamespace(id="RepoB@bbbb2222", path="/work/repo-b/repo-b-unity/Assets"), + ]) + + middleware = mod.UnityInstanceMiddleware() + ctx = DummyContext() + ctx.client_id = "client-1" + + selected = await middleware._maybe_autoselect_instance(ctx) + assert selected == "RepoA@aaaa1111" + assert await middleware.get_active_instance(ctx) == "RepoA@aaaa1111" + + +@pytest.mark.asyncio +async def test_autoselect_disambiguates_via_mcp_roots_no_env(monkeypatch): + """End-to-end stdio selection driven purely by MCP roots, with no env var.""" + mod = _load_middleware(monkeypatch, plugin_hub_configured=False) + monkeypatch.delenv("UNITY_MCP_PROJECT_DIR", raising=False) + monkeypatch.setattr(config, "transport_mode", "stdio") + _stub_pool(monkeypatch, [ + SimpleNamespace(id="RepoA@aaaa1111", path="/work/repo-a/repo-a-unity/Assets"), + SimpleNamespace(id="RepoB@bbbb2222", path="/work/repo-b/repo-b-unity/Assets"), + ]) + + middleware = mod.UnityInstanceMiddleware() + ctx = DummyContext() + ctx.client_id = "client-1" + + async def list_roots(): + """Advertise a single root pointing at repo-b's checkout.""" + return [_root("file:///work/repo-b")] + ctx.list_roots = list_roots + + selected = await middleware._maybe_autoselect_instance(ctx) + assert selected == "RepoB@bbbb2222" + assert await middleware.get_active_instance(ctx) == "RepoB@bbbb2222" + + +@pytest.mark.asyncio +async def test_autoselect_disambiguates_via_pluginhub(monkeypatch): + """End-to-end PluginHub selection, exercising HTTP project-root normalization.""" + mod = _load_middleware(monkeypatch, plugin_hub_configured=True) + PluginHub = sys.modules["transport.plugin_hub"].PluginHub + monkeypatch.setenv("UNITY_MCP_PROJECT_DIR", "/work/repo-b") + monkeypatch.setattr(config, "transport_mode", "http") + + async def fake_get_sessions(): + """Return two PluginHub sessions reporting project roots (no /Assets).""" + return SimpleNamespace( + sessions={ + "s1": SimpleNamespace( + project="RepoA", hash="aaaa1111", + project_path="/work/repo-a/repo-a-unity"), + "s2": SimpleNamespace( + project="RepoB", hash="bbbb2222", + project_path="/work/repo-b/repo-b-unity"), + } + ) + + monkeypatch.setattr(PluginHub, "get_sessions", fake_get_sessions) + + middleware = mod.UnityInstanceMiddleware() + ctx = DummyContext() + ctx.client_id = "client-1" + + selected = await middleware._maybe_autoselect_instance(ctx) + assert selected == "RepoB@bbbb2222" + assert await middleware.get_active_instance(ctx) == "RepoB@bbbb2222" + + +def test_session_details_exposes_project_path(monkeypatch): + """SessionDetails carries project_path and stays backward compatible.""" + _load_middleware(monkeypatch, plugin_hub_configured=False) + from transport.models import SessionDetails + + details = SessionDetails( + project="RepoA", hash="aaaa1111", unity_version="6000.0.0f1", + connected_at="2026-01-01T00:00:00", + project_path="/work/repo-a/repo-a-unity/Assets") + assert details.project_path == "/work/repo-a/repo-a-unity/Assets" + # Backward compatible: the field is optional and defaults to None. + legacy = SessionDetails( + project="RepoA", hash="aaaa1111", unity_version="6000.0.0f1", + connected_at="2026-01-01T00:00:00") + assert legacy.project_path is None