Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .projectmem/.current_issue
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0005
2 changes: 1 addition & 1 deletion API_REFERENCE.MD
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion DOCUMENTATION.MD
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 0 additions & 3 deletions Editor/MCPServer.Logs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 0 additions & 2 deletions Editor/MCPServer.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System;

Check warning on line 1 in Editor/MCPServer.cs

View workflow job for this annotation

GitHub Actions / Static validation

NQG002

File has 306 lines; consider splitting before it exceeds 450.

Check warning on line 1 in Editor/MCPServer.cs

View workflow job for this annotation

GitHub Actions / Documentation quality AI

NQG002

File has 306 lines; consider splitting before it exceeds 450.
using System.Collections.Concurrent;
using System.IO;
using System.Net;
Expand Down Expand Up @@ -107,7 +107,7 @@
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
private static void RuntimeInit() => Init();

[InitializeOnLoadMethod]

Check warning on line 110 in Editor/MCPServer.cs

View workflow job for this annotation

GitHub Actions / Static validation

NQG012 Init

Method has 57 lines; consider splitting before it exceeds 150.

Check warning on line 110 in Editor/MCPServer.cs

View workflow job for this annotation

GitHub Actions / Documentation quality AI

NQG012 Init

Method has 57 lines; consider splitting before it exceeds 150.
internal static void Init()
{
if (_mainThreadId == -1) _mainThreadId = Thread.CurrentThread.ManagedThreadId;
Expand Down Expand Up @@ -140,8 +140,6 @@
Application.logMessageReceived += OnLogMessageReceived;
#endif

UnityMCP.Runtime.MCPRuntimeLogger.OnLogReceived -= OnLogMessageReceived;
UnityMCP.Runtime.MCPRuntimeLogger.OnLogReceived += OnLogMessageReceived;
AssemblyReloadEvents.beforeAssemblyReload -= Cleanup;
AssemblyReloadEvents.beforeAssemblyReload += Cleanup;

Expand Down Expand Up @@ -192,7 +190,7 @@
/// The method initializes tool dispatch, resolves or allocates the configured port, records restart intent, may attach to an
/// existing Nexus Unity instance on the same port, and enables the macOS App Nap bypass before binding the listener.
/// </remarks>
public static void Start()

Check warning on line 193 in Editor/MCPServer.cs

View workflow job for this annotation

GitHub Actions / Static validation

NQG012 Start

Method has 72 lines; consider splitting before it exceeds 150.

Check warning on line 193 in Editor/MCPServer.cs

View workflow job for this annotation

GitHub Actions / Documentation quality AI

NQG012 Start

Method has 72 lines; consider splitting before it exceeds 150.
{
if (_mainThreadId != -1 && Thread.CurrentThread.ManagedThreadId != _mainThreadId)
{
Expand Down
21 changes: 4 additions & 17 deletions Editor/MCPServerMethods.Editor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<bool>();
else if (val.Type == JTokenType.Float) prop.floatValue = val.Value<float>();
else if (val.Type == JTokenType.Integer) prop.intValue = val.Value<int>();
else if (val.Type == JTokenType.String) prop.stringValue = val.Value<string>();
else if (val.Type == JTokenType.Object && val["x"] != null)
{
prop.vector3Value = new Vector3(val["x"].Value<float>(), val["y"].Value<float>(), val["z"].Value<float>());
}
else throw new System.Exception("Value type not supported for surgical edit yet");
}

/// <summary>Returns current editor state flags.</summary>
private static JToken GetEditorState(JToken p)
{
Expand Down
17 changes: 1 addition & 16 deletions Editor/MCPServerMethods.Prefab.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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" }); }
Expand All @@ -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<bool>();
else if (val.Type == JTokenType.Float) prop.floatValue = val.Value<float>();
else if (val.Type == JTokenType.Integer) prop.intValue = val.Value<int>();
else if (val.Type == JTokenType.String) prop.stringValue = val.Value<string>();
else if (val.Type == JTokenType.Object && val["x"] != null)
{
prop.vector3Value = new Vector3(val["x"].Value<float>(), val["y"].Value<float>(), val["z"].Value<float>());
}
else throw new Exception("Value type not supported for surgical asset edit yet");
}


}
}
11 changes: 11 additions & 0 deletions Editor/MCPServerMethods.Serialization.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool>();
else if (value.Type == JTokenType.Float) prop.floatValue = value.Value<float>();
else if (value.Type == JTokenType.Integer) prop.intValue = value.Value<int>();
else if (value.Type == JTokenType.String) prop.stringValue = value.Value<string>();
else if (value.Type == JTokenType.Object && value["x"] != null)
prop.vector3Value = new Vector3(value["x"].Value<float>(), value["y"].Value<float>(), value["z"].Value<float>());
else throw new Exception(unsupportedMessage);
}
}
}
3 changes: 3 additions & 0 deletions Editor/nexus_bridge/routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
37 changes: 35 additions & 2 deletions Editor/tests/test_routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}},
]

Expand All @@ -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"}},
]

Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion Runtime/MCPRuntimeLogger.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

63 changes: 9 additions & 54 deletions tools~/NexusQualityGate/OllamaChecklistReviewer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,17 @@ 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<string, CachedChecklistVerdict> _cache;
private readonly string _cachePath;
private bool _contactedOllama;

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<CachedChecklistVerdict>(_cachePath);
}

public async Task<IReadOnlyList<QualityGateIssue>> ReviewAsync()
Expand Down Expand Up @@ -69,7 +68,7 @@ public async Task<IReadOnlyList<QualityGateIssue>> 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();
}

Expand Down Expand Up @@ -146,7 +145,7 @@ private async Task<CachedChecklistVerdict> 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();

Expand All @@ -171,9 +170,9 @@ private async Task<CachedChecklistVerdict> 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)
{
Expand Down Expand Up @@ -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<AiChecklistVerdict>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
Expand All @@ -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)
Expand All @@ -459,36 +444,6 @@ private string BuildCacheKey(ChecklistItem item, string evidence)
return Convert.ToHexString(hash);
}

private static Dictionary<string, CachedChecklistVerdict> LoadCache(string path)
{
if (!File.Exists(path))
return new Dictionary<string, CachedChecklistVerdict>(StringComparer.Ordinal);

try
{
return JsonSerializer.Deserialize<Dictionary<string, CachedChecklistVerdict>>(File.ReadAllText(path))
?? new Dictionary<string, CachedChecklistVerdict>(StringComparer.Ordinal);
}
catch
{
return new Dictionary<string, CachedChecklistVerdict>(StringComparer.Ordinal);
}
}

private static void SaveCache(string path, Dictionary<string, CachedChecklistVerdict> 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
Expand Down
Loading
Loading