From 2c8283ccf69d16271281eb3adc82ed5b14c362a4 Mon Sep 17 00:00:00 2001 From: Daliys Date: Sun, 12 Jul 2026 01:17:03 +0200 Subject: [PATCH 01/49] Harden integration setup and add project memory hooks --- .githooks/post-commit | 8 ++++ .githooks/post-merge | 8 ++++ .githooks/pre-commit | 8 ++++ .gitignore | 12 ++++++ .projectmem/PROJECT_MAP.md | 47 +++++++++++++++++++++ .projectmem/config.toml | 3 ++ .projectmem/plan.md | 22 ++++++++++ AGENTS.md | 11 +++++ CHANGELOG.md | 3 ++ Editor/MCPServerWindow.Integrations.cs | 14 +++++- Tests~/Editor/EditorWindowUiToolkitTests.cs | 7 +++ 11 files changed, 142 insertions(+), 1 deletion(-) create mode 100755 .githooks/post-commit create mode 100755 .githooks/post-merge create mode 100755 .githooks/pre-commit create mode 100644 .projectmem/PROJECT_MAP.md create mode 100644 .projectmem/config.toml create mode 100644 .projectmem/plan.md diff --git a/.githooks/post-commit b/.githooks/post-commit new file mode 100755 index 0000000..419f1cc --- /dev/null +++ b/.githooks/post-commit @@ -0,0 +1,8 @@ +#!/usr/bin/env sh +set -eu + +REPO_ROOT=$(git rev-parse --show-toplevel) + +if [ -d "$REPO_ROOT/.projectmem" ] && command -v pjm >/dev/null 2>&1; then + (cd "$REPO_ROOT" && pjm _auto-capture commit >/dev/null 2>&1 &) +fi diff --git a/.githooks/post-merge b/.githooks/post-merge new file mode 100755 index 0000000..768f500 --- /dev/null +++ b/.githooks/post-merge @@ -0,0 +1,8 @@ +#!/usr/bin/env sh +set -eu + +REPO_ROOT=$(git rev-parse --show-toplevel) + +if [ -d "$REPO_ROOT/.projectmem" ] && command -v pjm >/dev/null 2>&1; then + (cd "$REPO_ROOT" && pjm _auto-capture merge >/dev/null 2>&1 &) +fi diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..e0f454f --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,8 @@ +#!/usr/bin/env sh +set -eu + +REPO_ROOT=$(git rev-parse --show-toplevel) + +if [ -d "$REPO_ROOT/.projectmem" ] && command -v pjm >/dev/null 2>&1; then + (cd "$REPO_ROOT" && pjm precheck --level warn) || true +fi diff --git a/.gitignore b/.gitignore index 04c5246..c5446f4 100644 --- a/.gitignore +++ b/.gitignore @@ -136,3 +136,15 @@ LINT_REPORT.txt temp_verify/ *.tmp AUDIT_FINDINGS.md* + +# ProjectMem runtime state is local and may contain private work history. +# Keep only the reviewed project configuration, map, and plan in Git. +.projectmem/AI_INSTRUCTIONS.md +.projectmem/events.jsonl +.projectmem/issues/ +.projectmem/summary.md +.projectmem/watch.pid +.projectmem/watch.log +.projectmem/structure.json +CLAUDE.md +AGENTS.md.soma-backup-* diff --git a/.projectmem/PROJECT_MAP.md b/.projectmem/PROJECT_MAP.md new file mode 100644 index 0000000..f07960a --- /dev/null +++ b/.projectmem/PROJECT_MAP.md @@ -0,0 +1,47 @@ +# Project Map - NexusUnity + +Status: curated project map. Update this map when package boundaries or primary entry points change. + +## Project purpose +Open source Unity Editor automation server for local AI tools and developer workflows. + +## Stack +- Unity Package Manager package: `com.forkhorizon.nexus.unity` for Unity `6000.0+`. +- C# Editor implementation under `Editor/`, with runtime support under `Runtime/`. +- Python 3 MCP bridge under `Editor/nexus_unity_bridge.py` and `Editor/nexus_bridge/`. +- Tests: Unity EditMode tests under `Tests~/Editor/` and Python bridge tests under `Editor/tests/`. +- Validation: `scripts/prepush-validate.sh`, GitHub Actions, and the .NET `tools~/NexusQualityGate` tool. + +## Entry points +- `package.json` — UPM package identity, dependencies, and public version. +- `Editor/MCPServer.cs` — local JSON-RPC server lifecycle and editor state. +- `Editor/MCPServerMethods.cs` — raw JSON-RPC dispatch; partial method files implement tool groups. +- `Editor/nexus_unity_bridge.py` — Python MCP bridge process entry point. +- `Editor/nexus_bridge/routing.py` — MCP manager routing and macros. + +## Structure +- `Editor/` — Unity Editor server, raw tools, integration UI, and Python bridge. + - `MCPServer.cs` — server state/lifecycle; `MCPServer.Networking.cs` owns HTTP/WebSocket handling. + - `MCPServerMethods.cs` — tool dispatch; `MCPServerMethods.*.cs` group the raw tool implementations. + - `NexusMcpConfigGenerator.cs` — generated MCP client configuration and bridge health checks. + - `nexus_unity_bridge.py` — MCP protocol entry point. + - `nexus_bridge/` — bridge transport, schemas, and manager routing. + - `tests/` — Python bridge unit tests. +- `Runtime/` — runtime assembly support used by Editor-facing features. +- `Tests~/Editor/` — Unity EditMode/API/security/integration configuration tests. +- `scripts/` — package validation and optional agent-tooling smoke script. +- `tools~/NexusQualityGate/` — standalone .NET documentation and source-quality gate. +- `.github/workflows/validate.yml` — protected-branch CI validation and Unity package smoke. +- `README.md`, `DOCUMENTATION.MD`, `API_REFERENCE.MD`, `RELEASE.md`, `CHANGELOG.md` — public package and release documentation. + +## Relationships +- `Editor/MCPServer.Networking.cs` validates a local request before `Editor/MCPServerMethods.cs` dispatches the raw JSON-RPC method on the Unity main thread. +- `Editor/nexus_unity_bridge.py` loads `Editor/nexus_bridge/routing.py`, which maps MCP manager tools to the raw Unity JSON-RPC API. +- `Editor/nexus_bridge/schemas.py` defines the public bridge contracts exercised by `Editor/tests/`. +- `Editor/NexusMcpConfigGenerator.cs` deploys the bridge and writes client configuration containing the current auth token. +- `scripts/prepush-validate.sh` runs bridge tests and `tools~/NexusQualityGate`; GitHub CI additionally imports the package into a clean Unity project. + +## Suggested first reads +- For API or server behavior: `API_REFERENCE.MD`, `Editor/MCPServer.cs`, and the relevant `Editor/MCPServerMethods.*.cs` file. +- For MCP bridge work: `Editor/nexus_unity_bridge.py`, `Editor/nexus_bridge/routing.py`, and `Editor/nexus_bridge/schemas.py`. +- For package publication: `RELEASE.md`, `CHANGELOG.md`, and `.github/workflows/validate.yml`. diff --git a/.projectmem/config.toml b/.projectmem/config.toml new file mode 100644 index 0000000..47db68c --- /dev/null +++ b/.projectmem/config.toml @@ -0,0 +1,3 @@ +summary_size_limit_kb = 20 +recent_days = 30 +project_description = "Nexus Unity is an open-source Unity Editor automation package with a C# JSON-RPC server and a Python MCP bridge." diff --git a/.projectmem/plan.md b/.projectmem/plan.md new file mode 100644 index 0000000..3843185 --- /dev/null +++ b/.projectmem/plan.md @@ -0,0 +1,22 @@ +# NexusUnity — plan + +> Editable **intent** file: ideas + plans — what we *mean to do*. +> This is NOT the event log. `events.jsonl` -> `summary.md` records what +> *happened*; this file records what we *intend*. The AI reads it at +> session start and edits it directly (like `PROJECT_MAP.md`): add ideas +> and plans, check items off, move done work down to Shipped. Plans are +> never logged as events. + +## Ideas +_Loose thoughts, not yet committed to._ + +## Active plans +_What we're working toward now. Use `- [ ]` / `- [x]` checklists._ + +## Next +_Queued, but not started._ + +## Someday / maybe + +## Shipped +_Move completed plans here so the top stays about the future._ diff --git a/AGENTS.md b/AGENTS.md index fa5b301..f84b7bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,3 +43,14 @@ PYTHONDONTWRITEBYTECODE=1 scripts/prepush-validate.sh --static-only For Unity-facing behavior, also use the running test harness through MCP tools such as `unity_wait`, `unity_lint_project`, and `unity_editor_controller`. Clean generated Python caches before validation if needed; `__pycache__` or `*.pyc` files are not part of package changes. + + +## Memory Tools + +Default mode: light. Do not spend tokens on memory tools for small, obvious, single-file tasks. + +- projectmem: use for bugs, regressions, multi-step changes, repeated attempts, or architecture decisions. For small self-contained edits, skip full memory startup and use targeted history checks only when useful. +- `.projectmem/config.toml`, `PROJECT_MAP.md`, and `plan.md` are shared project configuration. The append-only event log, issue files, generated summary/structure, watcher state, and generated `CLAUDE.md` bridge are local and ignored. +- This package uses the tracked `.githooks` directory. When `pjm` is installed, its `pre-commit`, `post-commit`, and `post-merge` wrappers provide warnings and local auto-capture; do not run `pjm hooks install`, which assumes `.git/hooks` exists. +- Verify that the projectmem MCP server is bound to this package before using write tools. If it is bound elsewhere, use the local `pjm` CLI from this package root instead. + diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f80c51..2ade587 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable public changes to Nexus Unity are documented here. ## [Unreleased] +### Fixed +- Auto Setup now reports a clear refresh-and-retry message if a client is unavailable after the integration list is regenerated, instead of throwing an exception. + ## [1.5.0] - 2026-07-12 ### Changed diff --git a/Editor/MCPServerWindow.Integrations.cs b/Editor/MCPServerWindow.Integrations.cs index 4838141..6229a1c 100644 --- a/Editor/MCPServerWindow.Integrations.cs +++ b/Editor/MCPServerWindow.Integrations.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.IO; using System.Linq; using UnityEditor; @@ -107,7 +108,13 @@ private void AutoSetupIntegration(NexusMcpClientInfo client) return; } - var refreshed = NexusMcpConfigGenerator.BuildAllForCurrentProject().First(item => item.Kind == client.Kind); + var refreshed = FindIntegrationByKind(NexusMcpConfigGenerator.BuildAllForCurrentProject(), client.Kind); + if (refreshed == null) + { + SetIntegrationMessage(client.DisplayName + " is no longer available. Refresh integrations and try again."); + return; + } + refreshed.BridgePath = bridgePath.Replace("\\", "/"); if (refreshed.CustomAutoSetup != null) @@ -134,6 +141,11 @@ private void AutoSetupIntegration(NexusMcpClientInfo client) SetIntegrationMessage(message); } + internal static NexusMcpClientInfo FindIntegrationByKind(IEnumerable clients, NexusMcpClientKind kind) + { + return clients.FirstOrDefault(item => item.Kind == kind); + } + private void CopyIntegrationConfig(NexusMcpClientInfo client) { EditorGUIUtility.systemCopyBuffer = client.ConfigText; diff --git a/Tests~/Editor/EditorWindowUiToolkitTests.cs b/Tests~/Editor/EditorWindowUiToolkitTests.cs index a175794..5a8fbb4 100644 --- a/Tests~/Editor/EditorWindowUiToolkitTests.cs +++ b/Tests~/Editor/EditorWindowUiToolkitTests.cs @@ -84,6 +84,13 @@ public void ServerWindowBuildsResponsiveUiToolkitSurface() } } + [Test] + public void IntegrationLookupReturnsNullWhenTheClientIsNoLongerAvailable() + { + Assert.IsNull(MCPServerWindow.FindIntegrationByKind( + Enumerable.Empty(), NexusMcpClientKind.Codex)); + } + [Test] public void VerificationWindowBuildsNamedControls() { From d42386ae254a2c0ce3b4eb1b883abc852d6d6957 Mon Sep 17 00:00:00 2001 From: Daliys Date: Sun, 12 Jul 2026 01:20:45 +0200 Subject: [PATCH 02/49] Ignore generated Claude MCP registration --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index c5446f4..500f31b 100644 --- a/.gitignore +++ b/.gitignore @@ -146,5 +146,9 @@ AUDIT_FINDINGS.md* .projectmem/watch.pid .projectmem/watch.log .projectmem/structure.json + +# Claude Code project registration is generated locally and includes the Nexus auth token. +.mcp.json +.mcp.json.bak-* CLAUDE.md AGENTS.md.soma-backup-* From 624bc0f69926afda4059d2d986b142708a95861b Mon Sep 17 00:00:00 2001 From: Daliys Date: Sun, 12 Jul 2026 16:27:55 +0200 Subject: [PATCH 03/49] fix: report Submitted status for async run_tests and dedupe Ollama review helpers run_tests now returns Submitted instead of a misleading Success before Unity finishes the test run; run_tests_wait returns rejected submissions immediately instead of polling until timeout. Also consolidates duplicate HttpClient setup, cache load/save, and JSON-extraction helpers from the two Ollama quality-gate reviewers into a shared OllamaReviewSupport class. Co-Authored-By: Claude Sonnet 5 --- .projectmem/.current_issue | 1 + API_REFERENCE.MD | 2 +- CHANGELOG.md | 4 ++ DOCUMENTATION.MD | 2 +- Editor/MCPServer.Logs.cs | 3 - Editor/MCPServer.cs | 2 - Editor/MCPServerMethods.Editor.cs | 21 ++---- Editor/MCPServerMethods.Prefab.cs | 17 +---- Editor/MCPServerMethods.Serialization.cs | 11 +++ Editor/nexus_bridge/routing.py | 3 + Editor/tests/test_routing.py | 37 +++++++++- README.md | 2 +- Runtime/MCPRuntimeLogger.cs.meta | 2 +- .../OllamaChecklistReviewer.cs | 63 +++------------- .../OllamaDocumentationReviewer.cs | 71 +++---------------- .../NexusQualityGate/OllamaReviewSupport.cs | 61 ++++++++++++++++ 16 files changed, 142 insertions(+), 160 deletions(-) create mode 100644 .projectmem/.current_issue create mode 100644 tools~/NexusQualityGate/OllamaReviewSupport.cs diff --git a/.projectmem/.current_issue b/.projectmem/.current_issue new file mode 100644 index 0000000..5aa937e --- /dev/null +++ b/.projectmem/.current_issue @@ -0,0 +1 @@ +0005 \ No newline at end of file diff --git a/API_REFERENCE.MD b/API_REFERENCE.MD index 33f3971..c732570 100644 --- a/API_REFERENCE.MD +++ b/API_REFERENCE.MD @@ -187,7 +187,7 @@ Actions: `search`, `explore`, `create_material`, `import`, `refresh`, `instantia ### `unity_editor_controller` Actions: `undo`, `redo`, `play`, `pause`, `step`, `menu`, `read_logs`, `clear_logs`, `get_state`, `get_server_status`, `refresh_assets`, `run_tests`, `get_test_results`, `run_tests_wait`, `get_tool_usage_stats`, `reset_tool_usage_stats`. -`run_tests_wait` triggers `run_tests` and polls raw `get_test_results` from the Python bridge so Unity's main thread is not blocked while the agent waits. +Raw `run_tests` returns `status: "Submitted"` after Unity accepts the asynchronous request; it does not claim that execution has begun or completed. `run_tests_wait` triggers `run_tests` and polls raw `get_test_results` from the Python bridge so Unity's main thread is not blocked while the agent waits. If submission is rejected, `run_tests_wait` returns that result without polling. ### `unity_ui_automation` Actions: `list_windows`, `get_hierarchy`, `query`, `get_window_rect`, `set_window_rect`, `capture_window_snapshot`, `click`, `input`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ade587..0d14858 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,11 @@ All notable public changes to Nexus Unity are documented here. ## [Unreleased] +### Changed +- Consolidated duplicate internal Ollama-review and serialized-property write helpers. + ### Fixed +- `run_tests` now reports `Submitted` rather than `Success` after Unity accepts its asynchronous request, and `run_tests_wait` now returns rejected submissions immediately instead of polling until timeout. - Auto Setup now reports a clear refresh-and-retry message if a client is unavailable after the integration list is regenerated, instead of throwing an exception. ## [1.5.0] - 2026-07-12 diff --git a/DOCUMENTATION.MD b/DOCUMENTATION.MD index f7ad418..c1dd03e 100644 --- a/DOCUMENTATION.MD +++ b/DOCUMENTATION.MD @@ -114,7 +114,7 @@ MCP bridge: - Manager tools accept common raw-action aliases where agents naturally try them, for example `unity_scene_manager action=list_scenes` and `unity_hierarchy_manager action=create_gameobject`. - Invalid manager actions include the valid action names in the error message. - `unity_hierarchy_manager` can create primitives with name, parent, transform, and material path; it can also rename, set transforms, and pass through `create_hierarchy`. -- `unity_editor_controller` includes `run_tests_wait`, which waits in the Python bridge by polling raw `get_test_results` instead of blocking the Unity main thread. +- Raw `run_tests` returns `Submitted` when Unity accepts the asynchronous request; it does not confirm that execution has begun or completed. `unity_editor_controller` `run_tests_wait` waits in the Python bridge by polling raw `get_test_results` instead of blocking the Unity main thread. - `unity_editor_controller` also exposes `get_tool_usage_stats` and `reset_tool_usage_stats` so diagnostics are not split between raw and manager workflows. - `unity_ui_automation` passes through `deep` for UI hierarchy reads, `class_name` for queries, and window rect/snapshot actions for layout checks. diff --git a/Editor/MCPServer.Logs.cs b/Editor/MCPServer.Logs.cs index f2d263c..c664e0b 100644 --- a/Editor/MCPServer.Logs.cs +++ b/Editor/MCPServer.Logs.cs @@ -124,9 +124,6 @@ private static void RuntimeInitLogs() { Application.logMessageReceivedThreaded -= OnLogMessageReceived; Application.logMessageReceivedThreaded += OnLogMessageReceived; - - UnityMCP.Runtime.MCPRuntimeLogger.OnLogReceived -= OnLogMessageReceived; - UnityMCP.Runtime.MCPRuntimeLogger.OnLogReceived += OnLogMessageReceived; } private static void OnLogMessageReceived(string condition, string stackTrace, LogType type) diff --git a/Editor/MCPServer.cs b/Editor/MCPServer.cs index 86e9b47..8e083af 100644 --- a/Editor/MCPServer.cs +++ b/Editor/MCPServer.cs @@ -140,8 +140,6 @@ internal static void Init() Application.logMessageReceived += OnLogMessageReceived; #endif - UnityMCP.Runtime.MCPRuntimeLogger.OnLogReceived -= OnLogMessageReceived; - UnityMCP.Runtime.MCPRuntimeLogger.OnLogReceived += OnLogMessageReceived; AssemblyReloadEvents.beforeAssemblyReload -= Cleanup; AssemblyReloadEvents.beforeAssemblyReload += Cleanup; diff --git a/Editor/MCPServerMethods.Editor.cs b/Editor/MCPServerMethods.Editor.cs index c729b67..744e77f 100644 --- a/Editor/MCPServerMethods.Editor.cs +++ b/Editor/MCPServerMethods.Editor.cs @@ -83,12 +83,12 @@ private static JToken RunTests(JToken p) return new JObject { - ["status"] = "Success", - ["message"] = $"Test run triggered for {modeStr} (filter: {filter ?? "none"})", + ["status"] = "Submitted", + ["message"] = $"Test run submitted for {modeStr} (filter: {filter ?? "none"})", ["mode"] = modeStr, ["filter"] = filter, ["result_path"] = GetDefaultTestResultsPath(), - ["started_at_utc"] = DateTime.UtcNow.ToString("o") + ["submitted_at_utc"] = DateTime.UtcNow.ToString("o") }; } catch (Exception e) @@ -219,25 +219,12 @@ private static JToken SetProperty(JToken p) if (prop == null) throw new System.Exception($"Property '{p["property_name"]}' not found on {obj.name}"); Undo.RecordObject(obj, $"Set {p["property_name"]}"); - ApplyValueToSerializedProperty(prop, p["value"]); + ApplySimpleJTokenValue(prop, p["value"], "Value type not supported for surgical edit yet"); so.ApplyModifiedProperties(); return new JObject { ["status"] = "Success", ["message"] = "Property updated" }; } - private static void ApplyValueToSerializedProperty(SerializedProperty prop, JToken val) - { - if (val.Type == JTokenType.Boolean) prop.boolValue = val.Value(); - else if (val.Type == JTokenType.Float) prop.floatValue = val.Value(); - else if (val.Type == JTokenType.Integer) prop.intValue = val.Value(); - else if (val.Type == JTokenType.String) prop.stringValue = val.Value(); - else if (val.Type == JTokenType.Object && val["x"] != null) - { - prop.vector3Value = new Vector3(val["x"].Value(), val["y"].Value(), val["z"].Value()); - } - else throw new System.Exception("Value type not supported for surgical edit yet"); - } - /// Returns current editor state flags. private static JToken GetEditorState(JToken p) { diff --git a/Editor/MCPServerMethods.Prefab.cs b/Editor/MCPServerMethods.Prefab.cs index be4b835..59899dd 100644 --- a/Editor/MCPServerMethods.Prefab.cs +++ b/Editor/MCPServerMethods.Prefab.cs @@ -133,7 +133,7 @@ private static JToken EditPrefabAsset(JToken p) if (prop != null) { - try { ApplyValueToSerializedPropertyAsset(prop, propPair.Value); updatedCount++; } + try { ApplySimpleJTokenValue(prop, propPair.Value, "Value type not supported for surgical asset edit yet"); updatedCount++; } catch (Exception e) { errors.Add(new JObject { ["field"] = propPair.Key, ["error"] = e.Message }); } } else { errors.Add(new JObject { ["field"] = propPair.Key, ["error"] = "Property not found" }); } @@ -156,20 +156,5 @@ private static JToken EditPrefabAsset(JToken p) } } - - private static void ApplyValueToSerializedPropertyAsset(SerializedProperty prop, JToken val) - { - if (val.Type == JTokenType.Boolean) prop.boolValue = val.Value(); - else if (val.Type == JTokenType.Float) prop.floatValue = val.Value(); - else if (val.Type == JTokenType.Integer) prop.intValue = val.Value(); - else if (val.Type == JTokenType.String) prop.stringValue = val.Value(); - else if (val.Type == JTokenType.Object && val["x"] != null) - { - prop.vector3Value = new Vector3(val["x"].Value(), val["y"].Value(), val["z"].Value()); - } - else throw new Exception("Value type not supported for surgical asset edit yet"); - } - - } } diff --git a/Editor/MCPServerMethods.Serialization.cs b/Editor/MCPServerMethods.Serialization.cs index d3127d0..c0531d1 100644 --- a/Editor/MCPServerMethods.Serialization.cs +++ b/Editor/MCPServerMethods.Serialization.cs @@ -307,5 +307,16 @@ private static void ApplyValueToProperty(SerializedProperty prop, object value) else if (value is string s) prop.stringValue = s; else if (value is Vector3 v) prop.vector3Value = v; } + + private static void ApplySimpleJTokenValue(SerializedProperty prop, JToken value, string unsupportedMessage) + { + if (value.Type == JTokenType.Boolean) prop.boolValue = value.Value(); + else if (value.Type == JTokenType.Float) prop.floatValue = value.Value(); + else if (value.Type == JTokenType.Integer) prop.intValue = value.Value(); + else if (value.Type == JTokenType.String) prop.stringValue = value.Value(); + else if (value.Type == JTokenType.Object && value["x"] != null) + prop.vector3Value = new Vector3(value["x"].Value(), value["y"].Value(), value["z"].Value()); + else throw new Exception(unsupportedMessage); + } } } diff --git a/Editor/nexus_bridge/routing.py b/Editor/nexus_bridge/routing.py index d920e0a..d0b7e8e 100644 --- a/Editor/nexus_bridge/routing.py +++ b/Editor/nexus_bridge/routing.py @@ -100,6 +100,9 @@ def _run_tests_wait(args: JsonObject) -> JsonRpcResponse: return trigger_response trigger_payload = _result_object(trigger_response) + if trigger_payload.get("status") not in ("Submitted", "Success"): + return {"result": trigger_payload} + result_path = trigger_payload.get("result_path") while time.time() - start_time < timeout: diff --git a/Editor/tests/test_routing.py b/Editor/tests/test_routing.py index bd3a50d..c000a71 100644 --- a/Editor/tests/test_routing.py +++ b/Editor/tests/test_routing.py @@ -197,7 +197,7 @@ class RunTestsWaitTests(unittest.TestCase): def test_run_tests_wait_returns_success_when_new_results_appear(self) -> None: call_results: list[dict[str, Any]] = [ {"result": {"status": "Success", "timestamp_utc": "2026-06-11T00:00:00Z"}}, - {"result": {"result_path": "/tmp/TestResults.xml"}}, + {"result": {"status": "Submitted", "result_path": "/tmp/TestResults.xml"}}, {"result": {"status": "Success", "timestamp_utc": "2026-06-11T00:00:03Z"}}, ] @@ -218,10 +218,24 @@ def test_run_tests_wait_returns_success_when_new_results_appear(self) -> None: ] ) + def test_run_tests_wait_accepts_legacy_success_trigger(self) -> None: + call_results: list[dict[str, Any]] = [ + {"result": {"status": "Success", "timestamp_utc": "2026-06-11T00:00:00Z"}}, + {"result": {"status": "Success", "result_path": "/tmp/TestResults.xml"}}, + {"result": {"status": "Success", "timestamp_utc": "2026-06-11T00:00:03Z"}}, + ] + + with patch("nexus_bridge.routing.call_unity", side_effect=call_results) as mock_call_unity: + with patch("nexus_bridge.routing.time.time", side_effect=[100.0, 100.0, 103.25]): + response: dict[str, Any] = routing._run_tests_wait({}) + + self.assertEqual("Success", response["result"]["status"]) + self.assertEqual(3, mock_call_unity.call_count) + def test_run_tests_wait_returns_timeout_when_results_do_not_change(self) -> None: call_results: list[dict[str, Any]] = [ {"result": {"status": "Success", "timestamp_utc": "2026-06-11T00:00:00Z"}}, - {"result": {"result_path": "/tmp/TestResults.xml"}}, + {"result": {"status": "Submitted", "result_path": "/tmp/TestResults.xml"}}, {"result": {"status": "Success", "timestamp_utc": "2026-06-11T00:00:00Z"}}, ] @@ -236,6 +250,25 @@ def test_run_tests_wait_returns_timeout_when_results_do_not_change(self) -> None self.assertEqual("Timed out waiting for a new Unity TestResults XML file.", response["result"]["message"]) mock_sleep.assert_called_once_with(1.0) + def test_run_tests_wait_returns_non_submitted_trigger_without_polling(self) -> None: + call_results: list[dict[str, Any]] = [ + {"result": {"status": "Success", "timestamp_utc": "2026-06-11T00:00:00Z"}}, + {"result": {"status": "Error", "message": "A test run is already active."}}, + ] + + with patch("nexus_bridge.routing.call_unity", side_effect=call_results) as mock_call_unity: + response: dict[str, Any] = routing._run_tests_wait({}) + + self.assertEqual("Error", response["result"]["status"]) + self.assertEqual("A test run is already active.", response["result"]["message"]) + mock_call_unity.assert_has_calls( + [ + call("get_test_results"), + call("run_tests", {"mode": "EditMode"}), + ] + ) + self.assertEqual(2, mock_call_unity.call_count) + class WaitForCompilationTests(unittest.TestCase): def test_wait_for_compilation_returns_ready_when_editor_finishes_compiling(self) -> None: diff --git a/README.md b/README.md index 97704a2..62a2bcc 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,7 @@ Current development keeps the public API backward-compatible while tightening sc - `invoke_method.arguments` is an optional positional JSON array. - `click_object_in_game` accepts a documented `instance_id` target and still supports hierarchy `path` lookup. - Unity 6.x editor identity differences are handled internally: Nexus keeps JSON `instance_id` values stable while using Unity 6.4+ `EntityId` APIs when available and Unity 6.3-compatible instance ID APIs otherwise. -- `get_test_results` reads Unity `TestResults*.xml` summaries from the project root or Unity persistent data path, using scoped failure/reason/output messages for non-passing cases; `unity_editor_controller` exposes `run_tests_wait` as a bridge-side polling workflow. +- `run_tests` returns `Submitted` only after Unity accepts the asynchronous request; use `get_test_results` for completed `TestResults*.xml` summaries or `unity_editor_controller` `run_tests_wait` to poll for completion without blocking Unity's main thread. - `get_tool_usage_stats` reports in-memory raw tool counts, durations, and errors since Unity domain load without storing request payloads; `reset_tool_usage_stats` clears that state for scoped diagnostics. Both are also available through `unity_editor_controller`. - `ui_get_window_rect`, `ui_set_window_rect`, and `ui_capture_window_snapshot` support automated layout checks for editor windows. diff --git a/Runtime/MCPRuntimeLogger.cs.meta b/Runtime/MCPRuntimeLogger.cs.meta index 2aa71e5..fa7de88 100644 --- a/Runtime/MCPRuntimeLogger.cs.meta +++ b/Runtime/MCPRuntimeLogger.cs.meta @@ -1,2 +1,2 @@ fileFormatVersion: 2 -guid: 7846c308efe274b05b4968bb0002d6f4 \ No newline at end of file +guid: 7846c308efe274b05b4968bb0002d6f4 diff --git a/tools~/NexusQualityGate/OllamaChecklistReviewer.cs b/tools~/NexusQualityGate/OllamaChecklistReviewer.cs index a3ead54..57356ea 100644 --- a/tools~/NexusQualityGate/OllamaChecklistReviewer.cs +++ b/tools~/NexusQualityGate/OllamaChecklistReviewer.cs @@ -11,7 +11,7 @@ public sealed class OllamaChecklistReviewer private const string RubricVersion = "2026-05-27-pr-checklist-review-v1"; private readonly QualityGateOptions _options; - private readonly HttpClient _client = new(); + private readonly HttpClient _client; private readonly Dictionary _cache; private readonly string _cachePath; private bool _contactedOllama; @@ -19,10 +19,9 @@ public sealed class OllamaChecklistReviewer public OllamaChecklistReviewer(QualityGateOptions options) { _options = options; - _client.Timeout = TimeSpan.FromSeconds(Math.Max(1, options.AiTimeoutSeconds)); - Directory.CreateDirectory(options.CacheDirectory); - _cachePath = Path.Combine(options.CacheDirectory, "ollama-checklist-verdicts.json"); - _cache = LoadCache(_cachePath); + _client = OllamaReviewSupport.CreateClient(options); + _cachePath = OllamaReviewSupport.GetCachePath(options, "ollama-checklist-verdicts.json"); + _cache = OllamaReviewSupport.LoadCache(_cachePath); } public async Task> ReviewAsync() @@ -69,7 +68,7 @@ public async Task> ReviewAsync() } await Task.WhenAll(tasks); - SaveCache(_cachePath, _cache); + OllamaReviewSupport.SaveCache(_cachePath, _cache); return issues.OrderBy(issue => issue.File, StringComparer.Ordinal).ThenBy(issue => issue.Line).ToList(); } @@ -146,7 +145,7 @@ private async Task RequestVerdictAsync(ChecklistItem ite using var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); _contactedOllama = true; - using HttpResponseMessage response = await _client.PostAsync(BuildOllamaEndpoint(), content); + using HttpResponseMessage response = await _client.PostAsync(OllamaReviewSupport.BuildChatEndpoint(_options), content); string responseBody = await response.Content.ReadAsStringAsync(); response.EnsureSuccessStatusCode(); @@ -171,9 +170,9 @@ private async Task RequestVerdictAsync(ChecklistItem ite }; using var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); - using HttpResponseMessage response = await _client.PostAsync(BuildOllamaGenerateEndpoint(), content); + using HttpResponseMessage response = await _client.PostAsync(OllamaReviewSupport.BuildGenerateEndpoint(_options), content); string responseBody = await response.Content.ReadAsStringAsync(); - return response.IsSuccessStatusCode ? null : $"{(int)response.StatusCode} {response.ReasonPhrase}: {TrimForLog(responseBody)}"; + return response.IsSuccessStatusCode ? null : $"{(int)response.StatusCode} {response.ReasonPhrase}: {OllamaReviewSupport.TrimForLog(responseBody)}"; } catch (Exception ex) { @@ -407,13 +406,9 @@ private static string[] BuildSearchTerms(string itemText) return terms.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); } - private Uri BuildOllamaEndpoint() => new(_options.OllamaUrl.TrimEnd('/') + "/api/chat"); - - private Uri BuildOllamaGenerateEndpoint() => new(_options.OllamaUrl.TrimEnd('/') + "/api/generate"); - private CachedChecklistVerdict ParseModelVerdict(string content) { - string json = ExtractJsonObject(content); + string json = OllamaReviewSupport.ExtractJsonObject(content); AiChecklistVerdict verdict = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true, @@ -426,16 +421,6 @@ private CachedChecklistVerdict ParseModelVerdict(string content) verdict.NextAction?.Trim() ?? string.Empty); } - private static string ExtractJsonObject(string content) - { - int start = content.IndexOf('{', StringComparison.Ordinal); - int end = content.LastIndexOf('}'); - if (start < 0 || end <= start) - throw new InvalidOperationException("Model did not return a JSON object."); - - return content[start..(end + 1)]; - } - private QualityGateIssue? ToIssue(ChecklistItem item, CachedChecklistVerdict verdict) { if (verdict.Pass) @@ -459,36 +444,6 @@ private string BuildCacheKey(ChecklistItem item, string evidence) return Convert.ToHexString(hash); } - private static Dictionary LoadCache(string path) - { - if (!File.Exists(path)) - return new Dictionary(StringComparer.Ordinal); - - try - { - return JsonSerializer.Deserialize>(File.ReadAllText(path)) - ?? new Dictionary(StringComparer.Ordinal); - } - catch - { - return new Dictionary(StringComparer.Ordinal); - } - } - - private static void SaveCache(string path, Dictionary cache) - { - string tempPath = path + ".tmp"; - File.WriteAllText(tempPath, JsonSerializer.Serialize(cache, new JsonSerializerOptions { WriteIndented = true })); - File.Move(tempPath, path, true); - } - - private static string TrimForLog(string value) - { - const int maxLength = 500; - value = value.ReplaceLineEndings(" ").Trim(); - return value.Length <= maxLength ? value : value[..maxLength] + "..."; - } - private sealed record CachedChecklistVerdict(bool Pass, string Severity, string Reason, string NextAction); private sealed class AiChecklistVerdict diff --git a/tools~/NexusQualityGate/OllamaDocumentationReviewer.cs b/tools~/NexusQualityGate/OllamaDocumentationReviewer.cs index d215c50..fb1db64 100644 --- a/tools~/NexusQualityGate/OllamaDocumentationReviewer.cs +++ b/tools~/NexusQualityGate/OllamaDocumentationReviewer.cs @@ -10,7 +10,7 @@ public sealed class OllamaDocumentationReviewer private const string RubricVersion = "2026-05-23-pragmatic-doc-review-v2"; private readonly QualityGateOptions _options; - private readonly HttpClient _client = new(); + private readonly HttpClient _client; private readonly Dictionary _cache; private readonly string _cachePath; private bool _contactedOllama; @@ -18,10 +18,9 @@ public sealed class OllamaDocumentationReviewer public OllamaDocumentationReviewer(QualityGateOptions options) { _options = options; - _client.Timeout = TimeSpan.FromSeconds(Math.Max(1, options.AiTimeoutSeconds)); - Directory.CreateDirectory(options.CacheDirectory); - _cachePath = Path.Combine(options.CacheDirectory, "ollama-verdicts.json"); - _cache = LoadCache(_cachePath); + _client = OllamaReviewSupport.CreateClient(options); + _cachePath = OllamaReviewSupport.GetCachePath(options, "ollama-verdicts.json"); + _cache = OllamaReviewSupport.LoadCache(_cachePath); } public async Task> ReviewAsync(IReadOnlyList candidates) @@ -59,7 +58,7 @@ public async Task> ReviewAsync(IReadOnlyList issue.File, StringComparer.Ordinal).ThenBy(issue => issue.Line).ToList(); } @@ -124,7 +123,7 @@ private async Task RequestVerdictAsync(DocumentationCandidate can using var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); _contactedOllama = true; - using HttpResponseMessage response = await _client.PostAsync(BuildOllamaEndpoint(), content); + using HttpResponseMessage response = await _client.PostAsync(OllamaReviewSupport.BuildChatEndpoint(_options), content); string responseBody = await response.Content.ReadAsStringAsync(); response.EnsureSuccessStatusCode(); @@ -149,10 +148,10 @@ private async Task RequestVerdictAsync(DocumentationCandidate can }; using var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); - using HttpResponseMessage response = await _client.PostAsync(BuildOllamaGenerateEndpoint(), content); + using HttpResponseMessage response = await _client.PostAsync(OllamaReviewSupport.BuildGenerateEndpoint(_options), content); string responseBody = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) - return $"{(int)response.StatusCode} {response.ReasonPhrase}: {TrimForLog(responseBody)}"; + return $"{(int)response.StatusCode} {response.ReasonPhrase}: {OllamaReviewSupport.TrimForLog(responseBody)}"; return null; } @@ -162,18 +161,6 @@ private async Task RequestVerdictAsync(DocumentationCandidate can } } - private Uri BuildOllamaEndpoint() - { - string baseUrl = _options.OllamaUrl.TrimEnd('/'); - return new Uri(baseUrl + "/api/chat"); - } - - private Uri BuildOllamaGenerateEndpoint() - { - string baseUrl = _options.OllamaUrl.TrimEnd('/'); - return new Uri(baseUrl + "/api/generate"); - } - private static string BuildPrompt(DocumentationCandidate candidate) { return $""" @@ -193,7 +180,7 @@ private static string BuildPrompt(DocumentationCandidate candidate) private CachedVerdict ParseModelVerdict(string content) { - string json = ExtractJsonObject(content); + string json = OllamaReviewSupport.ExtractJsonObject(content); AiVerdict verdict = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true, @@ -207,16 +194,6 @@ private CachedVerdict ParseModelVerdict(string content) return new CachedVerdict(pass, severity, reason, suggestedDoc); } - private static string ExtractJsonObject(string content) - { - int start = content.IndexOf('{', StringComparison.Ordinal); - int end = content.LastIndexOf('}'); - if (start < 0 || end <= start) - throw new InvalidOperationException("Model did not return a JSON object."); - - return content[start..(end + 1)]; - } - private QualityGateIssue? ToIssue(DocumentationCandidate candidate, CachedVerdict verdict) { if (verdict.Pass) @@ -240,36 +217,6 @@ private string BuildCacheKey(DocumentationCandidate candidate) return Convert.ToHexString(hash); } - private static Dictionary LoadCache(string path) - { - if (!File.Exists(path)) - return new Dictionary(StringComparer.Ordinal); - - try - { - return JsonSerializer.Deserialize>(File.ReadAllText(path)) - ?? new Dictionary(StringComparer.Ordinal); - } - catch - { - return new Dictionary(StringComparer.Ordinal); - } - } - - private static void SaveCache(string path, Dictionary cache) - { - string tempPath = path + ".tmp"; - File.WriteAllText(tempPath, JsonSerializer.Serialize(cache, new JsonSerializerOptions { WriteIndented = true })); - File.Move(tempPath, path, true); - } - - private static string TrimForLog(string value) - { - const int maxLength = 500; - value = value.ReplaceLineEndings(" ").Trim(); - return value.Length <= maxLength ? value : value[..maxLength] + "..."; - } - private sealed record CachedVerdict(bool Pass, string Severity, string Reason, string SuggestedDoc); private sealed class AiVerdict diff --git a/tools~/NexusQualityGate/OllamaReviewSupport.cs b/tools~/NexusQualityGate/OllamaReviewSupport.cs new file mode 100644 index 0000000..225f1f2 --- /dev/null +++ b/tools~/NexusQualityGate/OllamaReviewSupport.cs @@ -0,0 +1,61 @@ +using System.Text.Json; + +namespace NexusQualityGate; + +internal static class OllamaReviewSupport +{ + public static HttpClient CreateClient(QualityGateOptions options) => new() + { + Timeout = TimeSpan.FromSeconds(Math.Max(1, options.AiTimeoutSeconds)), + }; + + public static string GetCachePath(QualityGateOptions options, string fileName) + { + Directory.CreateDirectory(options.CacheDirectory); + return Path.Combine(options.CacheDirectory, fileName); + } + + public static Uri BuildChatEndpoint(QualityGateOptions options) => new(options.OllamaUrl.TrimEnd('/') + "/api/chat"); + + public static Uri BuildGenerateEndpoint(QualityGateOptions options) => new(options.OllamaUrl.TrimEnd('/') + "/api/generate"); + + public static string ExtractJsonObject(string content) + { + int start = content.IndexOf('{', StringComparison.Ordinal); + int end = content.LastIndexOf('}'); + if (start < 0 || end <= start) + throw new InvalidOperationException("Model did not return a JSON object."); + + return content[start..(end + 1)]; + } + + public static Dictionary LoadCache(string path) + { + if (!File.Exists(path)) + return new Dictionary(StringComparer.Ordinal); + + try + { + return JsonSerializer.Deserialize>(File.ReadAllText(path)) + ?? new Dictionary(StringComparer.Ordinal); + } + catch + { + return new Dictionary(StringComparer.Ordinal); + } + } + + public static void SaveCache(string path, Dictionary cache) + { + string tempPath = path + ".tmp"; + File.WriteAllText(tempPath, JsonSerializer.Serialize(cache, new JsonSerializerOptions { WriteIndented = true })); + File.Move(tempPath, path, true); + } + + public static string TrimForLog(string value) + { + const int maxLength = 500; + value = value.ReplaceLineEndings(" ").Trim(); + return value.Length <= maxLength ? value : value[..maxLength] + "..."; + } +} From d062b5210ef60f49eb0c01111e8d4659f51350b5 Mon Sep 17 00:00:00 2001 From: Daliys Date: Sun, 12 Jul 2026 18:46:13 +0200 Subject: [PATCH 04/49] Fix UI verification state isolation --- Editor/MCPTestWindow.cs | 18 +++++++--------- Editor/UIVerification.cs | 8 +++---- Tests~/Editor/EditorWindowUiToolkitTests.cs | 23 +++++++++++++++++++++ 3 files changed, 34 insertions(+), 15 deletions(-) diff --git a/Editor/MCPTestWindow.cs b/Editor/MCPTestWindow.cs index 61ba84f..be33c69 100644 --- a/Editor/MCPTestWindow.cs +++ b/Editor/MCPTestWindow.cs @@ -9,7 +9,7 @@ namespace UnityMCP.Editor /// /// /// The window builds a TextField, Button, and Label with stable names. Verification helpers show the window, reset state, - /// send UI automation JSON-RPC calls, and assert that UI callbacks update the static test state. + /// send UI automation JSON-RPC calls, and assert that UI callbacks update this window's test state. /// public class MCPTestWindow : EditorWindow { @@ -18,15 +18,15 @@ public class MCPTestWindow : EditorWindow /// /// Stores the last input value for verification. /// - public static string LastInputValue = ""; + public string LastInputValue { get; private set; } = ""; /// /// Tracks if the test button has been clicked. /// - public static bool ButtonClicked = false; + public bool ButtonClicked { get; private set; } /// - /// Resets static test flags and clears the UI Toolkit input/label elements in the current visual tree. + /// Resets this window's test state and clears the UI Toolkit input/label elements in the current visual tree. /// public void ResetState() { @@ -58,7 +58,7 @@ private void OnEnable() /// Creates named UI Toolkit controls and event handlers used by Nexus Unity UI automation smoke tests. /// /// - /// This method mutates , resets static test state, registers a text-change + /// This method mutates , preserves instance test state, registers a text-change /// callback, and wires the button click to update the label and . /// public void CreateGUI() @@ -66,9 +66,6 @@ public void CreateGUI() NexusEditorUi.SetupRoot(rootVisualElement); rootVisualElement.name = "NexusTestWindowRoot"; - LastInputValue = ""; - ButtonClicked = false; - var header = NexusEditorUi.Panel("TestWindowHeader"); header.Add(NexusEditorUi.Label("UI Automation Test", 16, true)); header.Add(NexusEditorUi.Label("Named controls used by Nexus Unity UI automation checks.", 11, false, NexusEditorUi.Muted)); @@ -76,7 +73,7 @@ public void CreateGUI() var panel = NexusEditorUi.Panel("TestWindowControls"); - var label = new Label("Initial State"); + var label = new Label(ButtonClicked ? "Button Clicked!" : "Initial State"); label.name = "TestLabel"; label.style.marginBottom = 8; label.style.unityFontStyleAndWeight = FontStyle.Bold; @@ -85,8 +82,7 @@ public void CreateGUI() var textField = new TextField("Input:"); textField.name = "TestInput"; textField.style.marginBottom = 8; - textField.value = ""; - LastInputValue = textField.value; + textField.value = LastInputValue; textField.RegisterValueChangedCallback(evt => LastInputValue = evt.newValue); panel.Add(textField); diff --git a/Editor/UIVerification.cs b/Editor/UIVerification.cs index 80c5680..848e882 100644 --- a/Editor/UIVerification.cs +++ b/Editor/UIVerification.cs @@ -26,7 +26,7 @@ public static void Verify() wnd.ResetState(); TestListAndHierarchy(); - TestInputAndClick(); + TestInputAndClick(wnd); NexusEditorLog.Log(NexusLogCategory.Diagnostics, "VERIFICATION SUCCESS", true); } @@ -38,13 +38,13 @@ private static void TestListAndHierarchy() if (!hier.Contains("TestButton")) throw new System.Exception("Hierarchy failed"); } - private static void TestInputAndClick() + private static void TestInputAndClick(MCPTestWindow wnd) { string txt = "Test"; Call("ui_input_text", new JObject { ["window_title"] = MCPTestWindow.WindowTitle, ["element_name"] = "TestInput", ["text"] = txt }); - if (MCPTestWindow.LastInputValue != txt) throw new System.Exception($"Input failed: expected '{txt}' but got '{MCPTestWindow.LastInputValue}'"); + if (wnd.LastInputValue != txt) throw new System.Exception($"Input failed: expected '{txt}' but got '{wnd.LastInputValue}'"); Call("ui_click", new JObject { ["window_title"] = MCPTestWindow.WindowTitle, ["element_name"] = "TestButton" }); - if (!MCPTestWindow.ButtonClicked) throw new System.Exception("Click failed"); + if (!wnd.ButtonClicked) throw new System.Exception("Click failed"); } private static string Call(string m, JObject p) diff --git a/Tests~/Editor/EditorWindowUiToolkitTests.cs b/Tests~/Editor/EditorWindowUiToolkitTests.cs index 5a8fbb4..a0cc5a8 100644 --- a/Tests~/Editor/EditorWindowUiToolkitTests.cs +++ b/Tests~/Editor/EditorWindowUiToolkitTests.cs @@ -130,6 +130,29 @@ public void TestWindowPreservesAutomationElementNames() } } + [Test] + public void TestWindowPreservesVerificationStateWhenRebuilt() + { + var window = ScriptableObject.CreateInstance(); + try + { + window.CreateGUI(); + window.rootVisualElement.Q("TestInput").value = "Persisted"; + window.rootVisualElement.Q