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] + "..."; + } +}