-
Notifications
You must be signed in to change notification settings - Fork 754
feat(manage_editor): add wait_for_compilation action #815
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: beta
Are you sure you want to change the base?
Changes from all commits
0c02112
a0dec49
fd9f96d
ed7418a
651e806
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| import time | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove unused
🧹 Proposed fix-import time
from typing import Annotated, Any, Literal timeout_s = max(1.0, min(timeout_s, 120.0))
- start = time.monotonic()
ready, elapsed = await wait_for_editor_ready(ctx, timeout_s=timeout_s)Also applies to: 76-76 🤖 Prompt for AI Agents |
||
| from typing import Annotated, Any, Literal | ||
|
|
||
| from fastmcp import Context | ||
|
|
@@ -12,37 +13,40 @@ | |
|
|
||
|
|
||
| @mcp_for_unity_tool( | ||
| description="Controls and queries the Unity editor's state and settings. Tip: pass booleans as true/false; if your client only sends strings, 'true'/'false' are accepted. Read-only actions: telemetry_status, telemetry_ping. Modifying actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer.", | ||
| description="Controls and queries the Unity editor's state and settings. Tip: pass booleans as true/false; if your client only sends strings, 'true'/'false' are accepted. Read-only actions: telemetry_status, telemetry_ping, wait_for_compilation. Modifying actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer.", | ||
| annotations=ToolAnnotations( | ||
| title="Manage Editor", | ||
| ), | ||
| ) | ||
| async def manage_editor( | ||
| ctx: Context, | ||
| action: Annotated[Literal["telemetry_status", "telemetry_ping", "play", "pause", "stop", "set_active_tool", "add_tag", "remove_tag", "add_layer", "remove_layer"], "Get and update the Unity Editor state."], | ||
| action: Annotated[Literal["telemetry_status", "telemetry_ping", "wait_for_compilation", "play", "pause", "stop", "set_active_tool", "add_tag", "remove_tag", "add_layer", "remove_layer"], "Get and update the Unity Editor state."], | ||
| wait_for_completion: Annotated[bool | str, | ||
| "Optional. If True, waits for certain actions (accepts true/false or 'true'/'false')"] | None = None, | ||
| timeout: Annotated[int | float | None, | ||
| "Timeout in seconds for wait_for_compilation (default: 30)."] = None, | ||
| tool_name: Annotated[str, | ||
| "Tool name when setting active tool"] | None = None, | ||
| tag_name: Annotated[str, | ||
| "Tag name when adding and removing tags"] | None = None, | ||
| layer_name: Annotated[str, | ||
| "Layer name when adding and removing layers"] | None = None, | ||
| ) -> dict[str, Any]: | ||
| # Get active instance from request state (injected by middleware) | ||
| unity_instance = get_unity_instance_from_context(ctx) | ||
|
|
||
| wait_for_completion = coerce_bool(wait_for_completion) | ||
|
|
||
| try: | ||
| # Diagnostics: quick telemetry checks | ||
| if action == "telemetry_status": | ||
| return {"success": True, "telemetry_enabled": is_telemetry_enabled()} | ||
|
|
||
| if action == "telemetry_ping": | ||
| record_tool_usage("diagnostic_ping", True, 1.0, None) | ||
| return {"success": True, "message": "telemetry ping queued"} | ||
| # Prepare parameters, removing None values | ||
|
|
||
| if action == "wait_for_compilation": | ||
| return await _wait_for_compilation(ctx, timeout) | ||
|
|
||
| params = { | ||
| "action": action, | ||
| "waitForCompletion": wait_for_completion, | ||
|
|
@@ -52,13 +56,42 @@ async def manage_editor( | |
| } | ||
| params = {k: v for k, v in params.items() if v is not None} | ||
|
|
||
| # Send command using centralized retry helper with instance routing | ||
| response = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "manage_editor", params) | ||
|
|
||
| # Preserve structured failure data; unwrap success into a friendlier shape | ||
| if isinstance(response, dict) and response.get("success"): | ||
| return {"success": True, "message": response.get("message", "Editor operation successful."), "data": response.get("data")} | ||
| return response if isinstance(response, dict) else {"success": False, "message": str(response)} | ||
|
|
||
| except Exception as e: | ||
| return {"success": False, "message": f"Python error managing editor: {str(e)}"} | ||
|
|
||
|
|
||
| async def _wait_for_compilation(ctx: Context, timeout: int | float | None) -> dict[str, Any]: | ||
| """Poll editor_state until compilation and domain reload finish.""" | ||
| from services.tools.refresh_unity import wait_for_editor_ready | ||
|
|
||
| timeout_s = float(timeout) if timeout is not None else 30.0 | ||
| timeout_s = max(1.0, min(timeout_s, 120.0)) | ||
|
|
||
| start = time.monotonic() | ||
| ready, elapsed = await wait_for_editor_ready(ctx, timeout_s=timeout_s) | ||
|
|
||
| if ready: | ||
| return { | ||
| "success": True, | ||
| "message": "Compilation complete. Editor is ready.", | ||
| "data": { | ||
| "waited_seconds": round(elapsed, 2), | ||
| "ready": True, | ||
| }, | ||
| } | ||
|
|
||
| return { | ||
| "success": False, | ||
| "message": f"Timed out after {timeout_s:.0f}s waiting for compilation to finish.", | ||
| "data": { | ||
| "waited_seconds": round(elapsed, 2), | ||
| "ready": False, | ||
| "timeout_seconds": timeout_s, | ||
| }, | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,137 @@ | ||
| import asyncio | ||
| import os | ||
| import time | ||
|
|
||
| import pytest | ||
|
|
||
| from .test_helpers import DummyContext | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_wait_for_compilation_returns_immediately_when_ready(monkeypatch): | ||
| """If compilation is already done, returns immediately with waited_seconds ~0.""" | ||
| from services.tools import manage_editor as mod | ||
| from services.tools import refresh_unity as refresh_mod | ||
|
|
||
| monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False) | ||
|
|
||
| async def fake_get_editor_state(ctx): | ||
| return {"data": {"advice": {"ready_for_tools": True, "blocking_reasons": []}}} | ||
|
|
||
| monkeypatch.setattr(refresh_mod.editor_state, "get_editor_state", fake_get_editor_state) | ||
|
|
||
| ctx = DummyContext() | ||
| result = await mod._wait_for_compilation(ctx, timeout=10) | ||
| assert result["success"] is True | ||
| assert result["data"]["ready"] is True | ||
| assert result["data"]["waited_seconds"] < 2.0 | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_wait_for_compilation_polls_until_ready(monkeypatch): | ||
| """Waits while compiling, returns success when compilation finishes.""" | ||
| from services.tools import manage_editor as mod | ||
| from services.tools import refresh_unity as refresh_mod | ||
|
|
||
| monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False) | ||
|
|
||
| call_count = 0 | ||
|
|
||
| async def fake_get_editor_state(ctx): | ||
| nonlocal call_count | ||
| call_count += 1 | ||
| if call_count < 3: | ||
| return {"data": {"advice": {"ready_for_tools": False, "blocking_reasons": ["compiling"]}}} | ||
| return {"data": {"advice": {"ready_for_tools": True, "blocking_reasons": []}}} | ||
|
|
||
| monkeypatch.setattr(refresh_mod.editor_state, "get_editor_state", fake_get_editor_state) | ||
|
|
||
| ctx = DummyContext() | ||
| result = await mod._wait_for_compilation(ctx, timeout=10) | ||
| assert result["success"] is True | ||
| assert result["data"]["ready"] is True | ||
| assert call_count >= 3 | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_wait_for_compilation_timeout(monkeypatch): | ||
| """Returns failure when compilation doesn't finish within timeout.""" | ||
| from services.tools import manage_editor as mod | ||
| from services.tools import refresh_unity as refresh_mod | ||
|
|
||
| monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False) | ||
|
|
||
| async def fake_get_editor_state(ctx): | ||
| return {"data": {"advice": {"ready_for_tools": False, "blocking_reasons": ["compiling"]}}} | ||
|
|
||
| monkeypatch.setattr(refresh_mod.editor_state, "get_editor_state", fake_get_editor_state) | ||
|
|
||
| ctx = DummyContext() | ||
| result = await mod._wait_for_compilation(ctx, timeout=1) | ||
| assert result["success"] is False | ||
| assert result["data"]["ready"] is False | ||
| assert result["data"]["timeout_seconds"] == 1.0 | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_wait_for_compilation_default_timeout(monkeypatch): | ||
| """None timeout defaults to 30s (clamped).""" | ||
| from services.tools import manage_editor as mod | ||
| from services.tools import refresh_unity as refresh_mod | ||
|
|
||
| monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False) | ||
|
|
||
| async def fake_get_editor_state(ctx): | ||
| return {"data": {"advice": {"ready_for_tools": True, "blocking_reasons": []}}} | ||
|
|
||
| monkeypatch.setattr(refresh_mod.editor_state, "get_editor_state", fake_get_editor_state) | ||
|
|
||
| ctx = DummyContext() | ||
| result = await mod._wait_for_compilation(ctx, timeout=None) | ||
| assert result["success"] is True | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_wait_for_compilation_via_manage_editor(monkeypatch): | ||
| """The action is routed correctly through the main manage_editor function.""" | ||
| from services.tools import manage_editor as mod | ||
| from services.tools import refresh_unity as refresh_mod | ||
|
|
||
| monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False) | ||
|
|
||
| async def fake_get_editor_state(ctx): | ||
| return {"data": {"advice": {"ready_for_tools": True, "blocking_reasons": []}}} | ||
|
|
||
| monkeypatch.setattr(refresh_mod.editor_state, "get_editor_state", fake_get_editor_state) | ||
|
|
||
| ctx = DummyContext() | ||
| result = await mod.manage_editor(ctx, action="wait_for_compilation", timeout=5) | ||
| assert result["success"] is True | ||
| assert result["data"]["ready"] is True | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_wait_for_compilation_domain_reload(monkeypatch): | ||
| """Waits through domain_reload blocking reason too.""" | ||
| from services.tools import manage_editor as mod | ||
| from services.tools import refresh_unity as refresh_mod | ||
|
|
||
| monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False) | ||
|
|
||
| call_count = 0 | ||
|
|
||
| async def fake_get_editor_state(ctx): | ||
| nonlocal call_count | ||
| call_count += 1 | ||
| if call_count == 1: | ||
| return {"data": {"advice": {"ready_for_tools": False, "blocking_reasons": ["compiling"]}}} | ||
| if call_count == 2: | ||
| return {"data": {"advice": {"ready_for_tools": False, "blocking_reasons": ["domain_reload"]}}} | ||
| return {"data": {"advice": {"ready_for_tools": True, "blocking_reasons": []}}} | ||
|
|
||
| monkeypatch.setattr(refresh_mod.editor_state, "get_editor_state", fake_get_editor_state) | ||
|
|
||
| ctx = DummyContext() | ||
| result = await mod._wait_for_compilation(ctx, timeout=10) | ||
| assert result["success"] is True | ||
| assert call_count >= 3 |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
waited_seconds: nullfrom the server bypasses the default and produces a malformed message.dict.get(key, default)only applies the default when the key is absent. If the server ever returns"waited_seconds": null, the expressionresult.get("data", {}).get("waited_seconds", 0)evaluates toNone, and the success message becomes"Compilation complete (waited Nones)".Additionally, a raw float like
2.3456would render verbosely in the output string.🛡️ Proposed fix
🤖 Prompt for AI Agents