From 6c8d6cf82c9e1c790f8657f02c7d8f282e4c0e19 Mon Sep 17 00:00:00 2001 From: lior Date: Wed, 25 Feb 2026 10:50:46 +0200 Subject: [PATCH 01/33] feat(tools): add ExecutionTier enum and annotate built-in tools Add ExecutionTier enum (Instant/Smooth/Heavy) to classify tool execution cost. Extend McpForUnityToolAttribute with Tier property (default Smooth). Annotate built-in tools: Heavy for manage_script, manage_shader, refresh_unity, run_tests. Instant for find_gameobjects, read_console, get_test_job. Co-Authored-By: Claude Opus 4.6 --- MCPForUnity/Editor/Tools/ExecutionTier.cs | 28 +++++++++++++++++++ MCPForUnity/Editor/Tools/FindGameObjects.cs | 2 +- MCPForUnity/Editor/Tools/GetTestJob.cs | 2 +- MCPForUnity/Editor/Tools/ManageScript.cs | 2 +- MCPForUnity/Editor/Tools/ManageShader.cs | 2 +- .../Editor/Tools/McpForUnityToolAttribute.cs | 6 ++++ MCPForUnity/Editor/Tools/ReadConsole.cs | 2 +- MCPForUnity/Editor/Tools/RefreshUnity.cs | 2 +- MCPForUnity/Editor/Tools/RunTests.cs | 2 +- 9 files changed, 41 insertions(+), 7 deletions(-) create mode 100644 MCPForUnity/Editor/Tools/ExecutionTier.cs diff --git a/MCPForUnity/Editor/Tools/ExecutionTier.cs b/MCPForUnity/Editor/Tools/ExecutionTier.cs new file mode 100644 index 000000000..4b32c25ac --- /dev/null +++ b/MCPForUnity/Editor/Tools/ExecutionTier.cs @@ -0,0 +1,28 @@ +namespace MCPForUnity.Editor.Tools +{ + /// + /// Classifies a tool's execution characteristics for queue dispatch. + /// + public enum ExecutionTier + { + /// + /// Read-only, microsecond-scale. Executes synchronously, returns inline. + /// Examples: find_gameobjects, read_console, scene hierarchy queries. + /// + Instant = 0, + + /// + /// Main-thread writes that don't trigger domain reload. Non-blocking. + /// Multiple smooth operations can coexist. + /// Examples: set_property, modify transform, material changes. + /// + Smooth = 1, + + /// + /// Triggers compilation, domain reload, or long-running processes. + /// Requires exclusive access — blocks all other operations. + /// Examples: script create/delete, compile, test runs, scene load. + /// + Heavy = 2 + } +} diff --git a/MCPForUnity/Editor/Tools/FindGameObjects.cs b/MCPForUnity/Editor/Tools/FindGameObjects.cs index d04f09429..57662944d 100644 --- a/MCPForUnity/Editor/Tools/FindGameObjects.cs +++ b/MCPForUnity/Editor/Tools/FindGameObjects.cs @@ -13,7 +13,7 @@ namespace MCPForUnity.Editor.Tools /// This is a focused search tool that returns lightweight results (IDs only). /// For detailed GameObject data, use the unity://scene/gameobject/{id} resource. /// - [McpForUnityTool("find_gameobjects")] + [McpForUnityTool("find_gameobjects", Tier = ExecutionTier.Instant)] public static class FindGameObjects { /// diff --git a/MCPForUnity/Editor/Tools/GetTestJob.cs b/MCPForUnity/Editor/Tools/GetTestJob.cs index 4817ab9bc..712c3ebd1 100644 --- a/MCPForUnity/Editor/Tools/GetTestJob.cs +++ b/MCPForUnity/Editor/Tools/GetTestJob.cs @@ -8,7 +8,7 @@ namespace MCPForUnity.Editor.Tools /// /// Poll a previously started async test job by job_id. /// - [McpForUnityTool("get_test_job", AutoRegister = false)] + [McpForUnityTool("get_test_job", AutoRegister = false, Tier = ExecutionTier.Instant)] public static class GetTestJob { public static object HandleCommand(JObject @params) diff --git a/MCPForUnity/Editor/Tools/ManageScript.cs b/MCPForUnity/Editor/Tools/ManageScript.cs index 619175125..af280672e 100644 --- a/MCPForUnity/Editor/Tools/ManageScript.cs +++ b/MCPForUnity/Editor/Tools/ManageScript.cs @@ -51,7 +51,7 @@ namespace MCPForUnity.Editor.Tools /// Note: Without Roslyn, the system falls back to basic structural validation. /// Roslyn provides full C# compiler diagnostics with line numbers and detailed error messages. /// - [McpForUnityTool("manage_script", AutoRegister = false)] + [McpForUnityTool("manage_script", AutoRegister = false, Tier = ExecutionTier.Heavy)] public static class ManageScript { /// diff --git a/MCPForUnity/Editor/Tools/ManageShader.cs b/MCPForUnity/Editor/Tools/ManageShader.cs index 849a6f932..cd2c4cac8 100644 --- a/MCPForUnity/Editor/Tools/ManageShader.cs +++ b/MCPForUnity/Editor/Tools/ManageShader.cs @@ -12,7 +12,7 @@ namespace MCPForUnity.Editor.Tools /// /// Handles CRUD operations for shader files within the Unity project. /// - [McpForUnityTool("manage_shader", AutoRegister = false)] + [McpForUnityTool("manage_shader", AutoRegister = false, Tier = ExecutionTier.Heavy)] public static class ManageShader { /// diff --git a/MCPForUnity/Editor/Tools/McpForUnityToolAttribute.cs b/MCPForUnity/Editor/Tools/McpForUnityToolAttribute.cs index e4db3a4c6..8b013b2b5 100644 --- a/MCPForUnity/Editor/Tools/McpForUnityToolAttribute.cs +++ b/MCPForUnity/Editor/Tools/McpForUnityToolAttribute.cs @@ -42,6 +42,12 @@ public class McpForUnityToolAttribute : Attribute /// public string PollAction { get; set; } = "status"; + /// + /// Execution tier for queue dispatch. Default: Smooth (safe conservative default). + /// Instant = read-only, inline. Smooth = non-blocking write. Heavy = exclusive access. + /// + public ExecutionTier Tier { get; set; } = ExecutionTier.Smooth; + /// /// The command name used to route requests to this tool. /// If not specified, defaults to the PascalCase class name converted to snake_case. diff --git a/MCPForUnity/Editor/Tools/ReadConsole.cs b/MCPForUnity/Editor/Tools/ReadConsole.cs index 3fe95f6b7..bb6baffb5 100644 --- a/MCPForUnity/Editor/Tools/ReadConsole.cs +++ b/MCPForUnity/Editor/Tools/ReadConsole.cs @@ -14,7 +14,7 @@ namespace MCPForUnity.Editor.Tools /// Handles reading and clearing Unity Editor console log entries. /// Uses reflection to access internal LogEntry methods/properties. /// - [McpForUnityTool("read_console", AutoRegister = false)] + [McpForUnityTool("read_console", AutoRegister = false, Tier = ExecutionTier.Instant)] public static class ReadConsole { // (Calibration removed) diff --git a/MCPForUnity/Editor/Tools/RefreshUnity.cs b/MCPForUnity/Editor/Tools/RefreshUnity.cs index 537472ac0..1786750c7 100644 --- a/MCPForUnity/Editor/Tools/RefreshUnity.cs +++ b/MCPForUnity/Editor/Tools/RefreshUnity.cs @@ -13,7 +13,7 @@ namespace MCPForUnity.Editor.Tools /// Explicitly refreshes Unity's asset database and optionally requests a script compilation. /// This is side-effectful and should be treated as a tool. /// - [McpForUnityTool("refresh_unity", AutoRegister = false)] + [McpForUnityTool("refresh_unity", AutoRegister = false, Tier = ExecutionTier.Heavy)] public static class RefreshUnity { private const int DefaultWaitTimeoutSeconds = 60; diff --git a/MCPForUnity/Editor/Tools/RunTests.cs b/MCPForUnity/Editor/Tools/RunTests.cs index abbf9225b..ed1cf39ef 100644 --- a/MCPForUnity/Editor/Tools/RunTests.cs +++ b/MCPForUnity/Editor/Tools/RunTests.cs @@ -12,7 +12,7 @@ namespace MCPForUnity.Editor.Tools /// Starts a Unity Test Runner run asynchronously and returns a job id immediately. /// Use get_test_job(job_id) to poll status/results. /// - [McpForUnityTool("run_tests", AutoRegister = false)] + [McpForUnityTool("run_tests", AutoRegister = false, Tier = ExecutionTier.Heavy)] public static class RunTests { public static Task HandleCommand(JObject @params) From 2891b10e25415915e0bc4fcaebd8462c7e85756e Mon Sep 17 00:00:00 2001 From: lior Date: Wed, 25 Feb 2026 10:50:52 +0200 Subject: [PATCH 02/33] feat(tools): add CommandClassifier for action-level tier overrides CommandClassifier.Classify() inspects action parameters to override the declared tool tier (e.g., manage_scene.get_hierarchy -> Instant, manage_scene.load -> Heavy). ClassifyBatch() returns the highest tier across a set of commands for queue scheduling. Co-Authored-By: Claude Opus 4.6 --- MCPForUnity/Editor/Tools/CommandClassifier.cs | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 MCPForUnity/Editor/Tools/CommandClassifier.cs diff --git a/MCPForUnity/Editor/Tools/CommandClassifier.cs b/MCPForUnity/Editor/Tools/CommandClassifier.cs new file mode 100644 index 000000000..c110a3406 --- /dev/null +++ b/MCPForUnity/Editor/Tools/CommandClassifier.cs @@ -0,0 +1,74 @@ +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Refines a tool's ExecutionTier based on action-level parameters. + /// Tools declare a base tier via [McpForUnityTool(Tier=...)]; this classifier + /// can promote or demote based on specific action strings or param values. + /// + public static class CommandClassifier + { + /// + /// Classify a single command. Returns the effective tier after action-level overrides. + /// + public static ExecutionTier Classify(string toolName, ExecutionTier attributeTier, JObject @params) + { + if (@params == null) return attributeTier; + + string action = @params.Value("action"); + + return toolName switch + { + "manage_scene" => ClassifyManageScene(action, attributeTier), + "refresh_unity" => ClassifyRefreshUnity(@params, attributeTier), + "manage_editor" => ClassifyManageEditor(action, attributeTier), + _ => attributeTier + }; + } + + /// + /// Classify a batch of commands. Returns the highest (most restrictive) tier. + /// + public static ExecutionTier ClassifyBatch( + (string toolName, ExecutionTier attributeTier, JObject @params)[] commands) + { + var max = ExecutionTier.Instant; + foreach (var (toolName, attributeTier, @params) in commands) + { + var tier = Classify(toolName, attributeTier, @params); + if (tier > max) max = tier; + } + return max; + } + + static ExecutionTier ClassifyManageScene(string action, ExecutionTier fallback) + { + return action switch + { + "get_hierarchy" or "get_active" or "get_build_settings" or "screenshot" + => ExecutionTier.Instant, + "create" or "load" or "save" + => ExecutionTier.Heavy, + _ => fallback + }; + } + + static ExecutionTier ClassifyRefreshUnity(JObject @params, ExecutionTier fallback) + { + string compile = @params.Value("compile"); + if (compile == "none") return ExecutionTier.Smooth; + return fallback; // Heavy by default + } + + static ExecutionTier ClassifyManageEditor(string action, ExecutionTier fallback) + { + return action switch + { + "telemetry_status" or "telemetry_ping" => ExecutionTier.Instant, + "play" or "pause" or "stop" => ExecutionTier.Heavy, + _ => fallback + }; + } + } +} From 3b8ee93917ab371fdfe97ae558f4a072d7f57784 Mon Sep 17 00:00:00 2001 From: lior Date: Wed, 25 Feb 2026 10:50:57 +0200 Subject: [PATCH 03/33] feat(tools): add BatchJob model and TicketStore for job lifecycle BatchJob tracks ticket, agent, commands, results, and status. TicketStore manages CRUD operations, ticket generation, expiry cleanup, and per-agent statistics. Foundation for the command gateway queue. Co-Authored-By: Claude Opus 4.6 --- MCPForUnity/Editor/Tools/BatchJob.cs | 45 ++++++++++++++ MCPForUnity/Editor/Tools/TicketStore.cs | 81 +++++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 MCPForUnity/Editor/Tools/BatchJob.cs create mode 100644 MCPForUnity/Editor/Tools/TicketStore.cs diff --git a/MCPForUnity/Editor/Tools/BatchJob.cs b/MCPForUnity/Editor/Tools/BatchJob.cs new file mode 100644 index 000000000..a8b7da932 --- /dev/null +++ b/MCPForUnity/Editor/Tools/BatchJob.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Tools +{ + public enum JobStatus { Queued, Running, Done, Failed, Cancelled } + + /// + /// Represents a queued batch of MCP commands with ticket tracking. + /// + public class BatchJob + { + public string Ticket { get; set; } + public string Agent { get; set; } + public string Label { get; set; } + public bool Atomic { get; set; } + public ExecutionTier Tier { get; set; } + public JobStatus Status { get; set; } = JobStatus.Queued; + + public List Commands { get; set; } = new(); + public List Results { get; set; } = new(); + public int CurrentIndex { get; set; } + public string Error { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? CompletedAt { get; set; } + + public int UndoGroup { get; set; } = -1; + } + + public class BatchCommand + { + public string Tool { get; set; } + public JObject Params { get; set; } + public ExecutionTier Tier { get; set; } + } + + public class AgentStats + { + public int Active { get; set; } + public int Queued { get; set; } + public int Completed { get; set; } + } +} diff --git a/MCPForUnity/Editor/Tools/TicketStore.cs b/MCPForUnity/Editor/Tools/TicketStore.cs new file mode 100644 index 000000000..4922c495a --- /dev/null +++ b/MCPForUnity/Editor/Tools/TicketStore.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Store for batch job tickets. Manages job lifecycle with auto-cleanup. + /// + public class TicketStore + { + readonly Dictionary _jobs = new(); + int _nextId; + + public BatchJob CreateJob(string agent, string label, bool atomic, ExecutionTier tier) + { + var job = new BatchJob + { + Ticket = $"t-{_nextId++:D6}", + Agent = agent ?? "anonymous", + Label = label ?? "", + Atomic = atomic, + Tier = tier + }; + _jobs[job.Ticket] = job; + return job; + } + + public BatchJob GetJob(string ticket) + { + return _jobs.TryGetValue(ticket, out var job) ? job : null; + } + + public void CleanExpired(TimeSpan expiry) + { + var expired = _jobs + .Where(kvp => (kvp.Value.Status == JobStatus.Done || kvp.Value.Status == JobStatus.Failed) + && kvp.Value.CompletedAt.HasValue + && DateTime.UtcNow - kvp.Value.CompletedAt.Value > expiry) + .Select(kvp => kvp.Key) + .ToList(); + foreach (var key in expired) + _jobs.Remove(key); + } + + public List GetQueuedJobs() + { + return _jobs.Values + .Where(j => j.Status == JobStatus.Queued) + .OrderBy(j => j.CreatedAt) + .ToList(); + } + + public List GetRunningJobs() + { + return _jobs.Values.Where(j => j.Status == JobStatus.Running).ToList(); + } + + public Dictionary GetAgentStats() + { + var stats = new Dictionary(); + foreach (var job in _jobs.Values) + { + if (!stats.TryGetValue(job.Agent, out var s)) + { + s = new AgentStats(); + stats[job.Agent] = s; + } + switch (job.Status) + { + case JobStatus.Running: s.Active++; break; + case JobStatus.Queued: s.Queued++; break; + case JobStatus.Done: s.Completed++; break; + } + } + return stats; + } + + public int QueueDepth => _jobs.Values.Count(j => j.Status == JobStatus.Queued); + } +} From e9933f66a9eb06e99347a0c07efa8c54fd694ef1 Mon Sep 17 00:00:00 2001 From: lior Date: Wed, 25 Feb 2026 10:51:04 +0200 Subject: [PATCH 04/33] feat(tools): add tier-aware CommandQueue and gateway state CommandQueue provides FIFO dispatch with tier awareness: instant jobs execute inline, smooth jobs run when no heavy is active, heavy jobs get exclusive access. CommandGatewayState hooks into EditorApplication .update for per-frame queue processing. CommandRegistry extended with GetToolTier() and tier storage in HandlerInfo. Co-Authored-By: Claude Opus 4.6 --- .../Editor/Tools/CommandGatewayState.cs | 25 +++ MCPForUnity/Editor/Tools/CommandQueue.cs | 212 ++++++++++++++++++ MCPForUnity/Editor/Tools/CommandRegistry.cs | 22 +- 3 files changed, 256 insertions(+), 3 deletions(-) create mode 100644 MCPForUnity/Editor/Tools/CommandGatewayState.cs create mode 100644 MCPForUnity/Editor/Tools/CommandQueue.cs diff --git a/MCPForUnity/Editor/Tools/CommandGatewayState.cs b/MCPForUnity/Editor/Tools/CommandGatewayState.cs new file mode 100644 index 000000000..7e219ca14 --- /dev/null +++ b/MCPForUnity/Editor/Tools/CommandGatewayState.cs @@ -0,0 +1,25 @@ +using UnityEditor; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Singleton state for the command gateway queue. + /// Hooks into EditorApplication.update for tick processing. + /// + [InitializeOnLoad] + public static class CommandGatewayState + { + public static readonly CommandQueue Queue = new(); + + static CommandGatewayState() + { + EditorApplication.update += OnUpdate; + } + + static void OnUpdate() + { + Queue.ProcessTick(async (tool, @params) => + await CommandRegistry.InvokeCommandAsync(tool, @params)); + } + } +} diff --git a/MCPForUnity/Editor/Tools/CommandQueue.cs b/MCPForUnity/Editor/Tools/CommandQueue.cs new file mode 100644 index 000000000..382a38a36 --- /dev/null +++ b/MCPForUnity/Editor/Tools/CommandQueue.cs @@ -0,0 +1,212 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using UnityEditor; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Tier-aware command queue. Processes jobs via EditorApplication.update. + /// Instant: execute inline. Smooth: execute when no heavy active. Heavy: exclusive. + /// + public class CommandQueue + { + readonly TicketStore _store = new(); + readonly Queue _heavyQueue = new(); + readonly List _smoothInFlight = new(); + string _activeHeavyTicket; + + static readonly TimeSpan TicketExpiry = TimeSpan.FromMinutes(5); + + public bool HasActiveHeavy => _activeHeavyTicket != null; + public int QueueDepth => _store.QueueDepth; + public int SmoothInFlight => _smoothInFlight.Count; + + /// + /// Submit a batch of commands. Returns the BatchJob with ticket. + /// + public BatchJob Submit(string agent, string label, bool atomic, List commands) + { + var batchTier = CommandClassifier.ClassifyBatch( + commands.Select(c => (c.Tool, c.Tier, c.Params)).ToArray()); + + var job = _store.CreateJob(agent, label, atomic, batchTier); + job.Commands = commands; + + if (batchTier == ExecutionTier.Heavy) + _heavyQueue.Enqueue(job.Ticket); + // Smooth and Instant are handled differently (see ProcessTick) + + return job; + } + + /// + /// Poll a job's status. + /// + public BatchJob Poll(string ticket) => _store.GetJob(ticket); + + /// + /// Cancel a queued job. Only the owning agent can cancel. + /// + public bool Cancel(string ticket, string agent) + { + var job = _store.GetJob(ticket); + if (job == null || job.Status != JobStatus.Queued) return false; + if (job.Agent != agent && agent != null) return false; + + job.Status = JobStatus.Cancelled; + job.CompletedAt = DateTime.UtcNow; + return true; + } + + /// + /// Get jobs ahead of the given ticket in the queue. + /// + public List GetAheadOf(string ticket) + { + var queued = _store.GetQueuedJobs(); + var result = new List(); + foreach (var j in queued) + { + if (j.Ticket == ticket) break; + result.Add(j); + } + // Also include the active heavy job + if (_activeHeavyTicket != null) + { + var active = _store.GetJob(_activeHeavyTicket); + if (active != null) + result.Insert(0, active); + } + return result; + } + + /// + /// Get overall queue status. + /// + public object GetStatus() + { + var activeHeavy = _activeHeavyTicket != null ? _store.GetJob(_activeHeavyTicket) : null; + return new + { + queue_depth = QueueDepth, + active_heavy = activeHeavy != null ? new + { + ticket = activeHeavy.Ticket, + agent = activeHeavy.Agent, + label = activeHeavy.Label, + progress = $"{activeHeavy.CurrentIndex}/{activeHeavy.Commands.Count}" + } : null, + smooth_in_flight = _smoothInFlight.Count, + agents = _store.GetAgentStats() + }; + } + + /// + /// Called every EditorApplication.update frame. Processes the queue. + /// + public void ProcessTick(Func> executeCommand) + { + _store.CleanExpired(TicketExpiry); + + // Clean completed smooth jobs + _smoothInFlight.RemoveAll(ticket => + { + var j = _store.GetJob(ticket); + return j == null || j.Status == JobStatus.Done || j.Status == JobStatus.Failed; + }); + + // 1. Check if active heavy finished + if (_activeHeavyTicket != null) + { + var heavy = _store.GetJob(_activeHeavyTicket); + if (heavy != null && (heavy.Status == JobStatus.Done || heavy.Status == JobStatus.Failed)) + _activeHeavyTicket = null; + else + return; // Heavy still running, wait + } + + // 2. If heavy queue has items and no smooth in flight, start next heavy + if (_heavyQueue.Count > 0 && _smoothInFlight.Count == 0) + { + while (_heavyQueue.Count > 0) + { + var ticket = _heavyQueue.Dequeue(); + var job = _store.GetJob(ticket); + if (job == null || job.Status == JobStatus.Cancelled) continue; + _activeHeavyTicket = ticket; + _ = ExecuteJob(job, executeCommand); + return; + } + } + + // 3. No heavy active — process smooth jobs + var smoothQueued = _store.GetQueuedJobs() + .Where(j => j.Tier == ExecutionTier.Smooth) + .ToList(); + foreach (var job in smoothQueued) + { + _smoothInFlight.Add(job.Ticket); + _ = ExecuteJob(job, executeCommand); + } + } + + /// + /// Execute all commands in a job sequentially. + /// + async Task ExecuteJob(BatchJob job, Func> executeCommand) + { + job.Status = JobStatus.Running; + + if (job.Atomic) + { + Undo.IncrementCurrentGroup(); + job.UndoGroup = Undo.GetCurrentGroup(); + Undo.SetCurrentGroupName($"Gateway: {job.Label}"); + } + + try + { + for (int i = 0; i < job.Commands.Count; i++) + { + job.CurrentIndex = i; + var cmd = job.Commands[i]; + + var result = await executeCommand(cmd.Tool, cmd.Params); + job.Results.Add(result); + + // Check for failure + if (result is IMcpResponse resp && !resp.Success) + { + if (job.Atomic) + { + Undo.RevertAllInCurrentGroup(); + job.Error = $"Command {i} ({cmd.Tool}) failed. Batch rolled back."; + job.Status = JobStatus.Failed; + job.CompletedAt = DateTime.UtcNow; + return; + } + } + } + + if (job.Atomic && job.UndoGroup >= 0) + Undo.CollapseUndoOperations(job.UndoGroup); + + job.Status = JobStatus.Done; + } + catch (Exception ex) + { + if (job.Atomic && job.UndoGroup >= 0) + Undo.RevertAllInCurrentGroup(); + job.Error = ex.Message; + job.Status = JobStatus.Failed; + } + finally + { + job.CompletedAt = DateTime.UtcNow; + } + } + } +} diff --git a/MCPForUnity/Editor/Tools/CommandRegistry.cs b/MCPForUnity/Editor/Tools/CommandRegistry.cs index ca39ea51c..a30bfcf77 100644 --- a/MCPForUnity/Editor/Tools/CommandRegistry.cs +++ b/MCPForUnity/Editor/Tools/CommandRegistry.cs @@ -18,14 +18,16 @@ class HandlerInfo public string CommandName { get; } public Func SyncHandler { get; } public Func> AsyncHandler { get; } + public ExecutionTier Tier { get; } public bool IsAsync => AsyncHandler != null; - public HandlerInfo(string commandName, Func syncHandler, Func> asyncHandler) + public HandlerInfo(string commandName, Func syncHandler, Func> asyncHandler, ExecutionTier tier = ExecutionTier.Smooth) { CommandName = commandName; SyncHandler = syncHandler; AsyncHandler = asyncHandler; + Tier = tier; } } @@ -102,17 +104,20 @@ private static bool RegisterCommandType(Type type, bool isResource) { string commandName; string typeLabel = isResource ? "resource" : "tool"; + ExecutionTier tier = ExecutionTier.Smooth; // default // Get command name from appropriate attribute if (isResource) { var resourceAttr = type.GetCustomAttribute(); commandName = resourceAttr.ResourceName; + tier = ExecutionTier.Instant; // Resources are read-only } else { var toolAttr = type.GetCustomAttribute(); commandName = toolAttr.CommandName; + tier = toolAttr.Tier; } // Auto-generate command name if not explicitly provided @@ -155,7 +160,7 @@ private static bool RegisterCommandType(Type type, bool isResource) if (typeof(Task).IsAssignableFrom(method.ReturnType)) { var asyncHandler = CreateAsyncHandlerDelegate(method, commandName); - handlerInfo = new HandlerInfo(commandName, null, asyncHandler); + handlerInfo = new HandlerInfo(commandName, null, asyncHandler, tier); } else { @@ -163,7 +168,7 @@ private static bool RegisterCommandType(Type type, bool isResource) typeof(Func), method ); - handlerInfo = new HandlerInfo(commandName, handler, null); + handlerInfo = new HandlerInfo(commandName, handler, null, tier); } _handlers[commandName] = handlerInfo; @@ -190,6 +195,17 @@ private static HandlerInfo GetHandlerInfo(string commandName) return handler; } + /// + /// Get the declared ExecutionTier for a registered tool. + /// Returns Smooth as default for unknown tools. + /// + public static ExecutionTier GetToolTier(string commandName) + { + if (_handlers.TryGetValue(commandName, out var handler)) + return handler.Tier; + return ExecutionTier.Smooth; + } + /// /// Get a synchronous command handler by name. /// Throws if the command is asynchronous. From e7e3e56ee52e1a081f1aea41f39338080ce9f85b Mon Sep 17 00:00:00 2001 From: lior Date: Wed, 25 Feb 2026 10:51:10 +0200 Subject: [PATCH 05/33] feat(tools): extend BatchExecute with async gateway path When async=true is passed, BatchExecute now routes through CommandGatewayState.Queue instead of executing inline. Returns a ticket for non-instant batches (poll via poll_job) or results directly for instant batches. Co-Authored-By: Claude Opus 4.6 --- MCPForUnity/Editor/Tools/BatchExecute.cs | 88 ++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/MCPForUnity/Editor/Tools/BatchExecute.cs b/MCPForUnity/Editor/Tools/BatchExecute.cs index aca4a1994..f51a9cec3 100644 --- a/MCPForUnity/Editor/Tools/BatchExecute.cs +++ b/MCPForUnity/Editor/Tools/BatchExecute.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using MCPForUnity.Editor.Constants; using MCPForUnity.Editor.Helpers; @@ -51,6 +52,14 @@ public static async Task HandleCommand(JObject @params) $"A maximum of {maxCommands} commands are allowed per batch (configurable in MCP Tools window, hard max {AbsoluteMaxCommandsPerBatch})."); } + // --- Async gateway path --- + bool isAsync = @params.Value("async") ?? false; + if (isAsync) + { + return HandleAsyncSubmit(@params, commandsToken); + } + + // --- Legacy synchronous path (unchanged) --- bool failFast = @params.Value("failFast") ?? false; bool parallelRequested = @params.Value("parallel") ?? false; int? maxParallel = @params.Value("maxParallelism"); @@ -231,5 +240,84 @@ private static JObject NormalizeParameterKeys(JObject source) } private static string ToCamelCase(string key) => StringCaseUtility.ToCamelCase(key); + + /// + /// Handle async batch submission. Queues commands via CommandGateway and returns + /// a ticket (for non-instant batches) or results inline (for instant batches). + /// + private static object HandleAsyncSubmit(JObject @params, JArray commandsToken) + { + bool atomic = @params.Value("atomic") ?? false; + string agent = @params.Value("agent") ?? "anonymous"; + string label = @params.Value("label") ?? ""; + + var commands = new List(); + foreach (var token in commandsToken) + { + if (token is not JObject cmdObj) continue; + string toolName = cmdObj["tool"]?.ToString(); + if (string.IsNullOrWhiteSpace(toolName)) continue; + + var rawParams = cmdObj["params"] as JObject ?? new JObject(); + var cmdParams = NormalizeParameterKeys(rawParams); + + var toolTier = CommandRegistry.GetToolTier(toolName); + var effectiveTier = CommandClassifier.Classify(toolName, toolTier, cmdParams); + + commands.Add(new BatchCommand { Tool = toolName, Params = cmdParams, Tier = effectiveTier }); + } + + if (commands.Count == 0) + { + return new ErrorResponse("No valid commands in async batch."); + } + + var job = CommandGatewayState.Queue.Submit(agent, label, atomic, commands); + + if (job.Tier == ExecutionTier.Instant) + { + // Execute inline, return results directly + foreach (var cmd in commands) + { + try + { + var result = CommandRegistry.InvokeCommandAsync(cmd.Tool, cmd.Params) + .ConfigureAwait(true).GetAwaiter().GetResult(); + job.Results.Add(result); + } + catch (Exception ex) + { + job.Results.Add(new ErrorResponse(ex.Message)); + if (atomic) + { + job.Status = JobStatus.Failed; + job.Error = ex.Message; + job.CompletedAt = DateTime.UtcNow; + return new ErrorResponse($"Instant batch failed at command '{cmd.Tool}': {ex.Message}", + new { ticket = job.Ticket, results = job.Results }); + } + } + } + job.Status = JobStatus.Done; + job.CompletedAt = DateTime.UtcNow; + return new SuccessResponse("Batch completed (instant).", + new { ticket = job.Ticket, results = job.Results }); + } + + // Non-instant: return ticket for polling + return new PendingResponse( + $"Batch queued as {job.Ticket}. Poll with poll_job.", + pollIntervalSeconds: 2.0, + data: new + { + ticket = job.Ticket, + status = job.Status.ToString().ToLowerInvariant(), + position = CommandGatewayState.Queue.GetAheadOf(job.Ticket).Count, + tier = job.Tier.ToString().ToLowerInvariant(), + agent, + label, + atomic + }); + } } } From 13e2dca20f6be2823805ba3f1f9d783a1f796aea Mon Sep 17 00:00:00 2001 From: lior Date: Wed, 25 Feb 2026 10:51:15 +0200 Subject: [PATCH 06/33] feat(tools): add PollJob and QueueStatus tools PollJob (Instant tier) returns job status with position/progress/results. QueueStatus (Instant tier) returns queue depth, active heavy job info, smooth in-flight count, and per-agent statistics. Co-Authored-By: Claude Opus 4.6 --- MCPForUnity/Editor/Tools/PollJob.cs | 101 ++++++++++++++++++++++++ MCPForUnity/Editor/Tools/QueueStatus.cs | 19 +++++ 2 files changed, 120 insertions(+) create mode 100644 MCPForUnity/Editor/Tools/PollJob.cs create mode 100644 MCPForUnity/Editor/Tools/QueueStatus.cs diff --git a/MCPForUnity/Editor/Tools/PollJob.cs b/MCPForUnity/Editor/Tools/PollJob.cs new file mode 100644 index 000000000..795080b28 --- /dev/null +++ b/MCPForUnity/Editor/Tools/PollJob.cs @@ -0,0 +1,101 @@ +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Poll the status of an async batch job by ticket ID. + /// Generalizes the get_test_job pattern to any queued batch. + /// + [McpForUnityTool("poll_job", Tier = ExecutionTier.Instant)] + public static class PollJob + { + public static object HandleCommand(JObject @params) + { + var p = new ToolParams(@params); + var ticketResult = p.GetRequired("ticket"); + if (!ticketResult.IsSuccess) + return new ErrorResponse(ticketResult.ErrorMessage); + + string ticket = ticketResult.Value; + var job = CommandGatewayState.Queue.Poll(ticket); + if (job == null) + return new ErrorResponse($"Ticket '{ticket}' not found or expired."); + + switch (job.Status) + { + case JobStatus.Queued: + var ahead = CommandGatewayState.Queue.GetAheadOf(ticket); + return new PendingResponse( + $"Queued at position {ahead.Count}.", + pollIntervalSeconds: 2.0, + data: new + { + ticket = job.Ticket, + status = "queued", + position = ahead.Count, + agent = job.Agent, + label = job.Label, + ahead = ahead.ConvertAll(j => (object)new + { + ticket = j.Ticket, + agent = j.Agent, + label = j.Label, + tier = j.Tier.ToString().ToLowerInvariant(), + status = j.Status.ToString().ToLowerInvariant() + }) + }); + + case JobStatus.Running: + return new PendingResponse( + $"Running command {job.CurrentIndex + 1}/{job.Commands.Count}.", + pollIntervalSeconds: 1.0, + data: new + { + ticket = job.Ticket, + status = "running", + progress = $"{job.CurrentIndex + 1}/{job.Commands.Count}", + agent = job.Agent, + label = job.Label + }); + + case JobStatus.Done: + return new SuccessResponse( + $"Batch complete. {job.Results.Count} results.", + new + { + ticket = job.Ticket, + status = "done", + results = job.Results, + agent = job.Agent, + label = job.Label, + atomic = job.Atomic + }); + + case JobStatus.Failed: + return new ErrorResponse( + job.Error ?? "Batch failed.", + new + { + ticket = job.Ticket, + status = "failed", + results = job.Results, + error = job.Error, + failed_at_command = job.CurrentIndex, + atomic = job.Atomic, + rolled_back = job.Atomic + }); + + case JobStatus.Cancelled: + return new ErrorResponse("Job was cancelled.", new + { + ticket = job.Ticket, + status = "cancelled" + }); + + default: + return new ErrorResponse($"Unknown status: {job.Status}"); + } + } + } +} diff --git a/MCPForUnity/Editor/Tools/QueueStatus.cs b/MCPForUnity/Editor/Tools/QueueStatus.cs new file mode 100644 index 000000000..63114351c --- /dev/null +++ b/MCPForUnity/Editor/Tools/QueueStatus.cs @@ -0,0 +1,19 @@ +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Get overall command queue status. No ticket needed. + /// Shows queue depth, active heavy job, smooth in-flight count, per-agent stats. + /// + [McpForUnityTool("queue_status", Tier = ExecutionTier.Instant)] + public static class QueueStatus + { + public static object HandleCommand(JObject @params) + { + var status = CommandGatewayState.Queue.GetStatus(); + return new SuccessResponse("Queue status.", status); + } + } +} From 09d40c9c451dd0137f898d2ce577e7d9c54f07a4 Mon Sep 17 00:00:00 2001 From: lior Date: Wed, 25 Feb 2026 10:51:22 +0200 Subject: [PATCH 07/33] test(tools): add unit tests for command gateway components 45 tests covering: - ExecutionTier enum ordering and attribute defaults (5) - CommandClassifier action-level overrides and batching (13) - TicketStore CRUD, expiry, agent stats (10) - CommandQueue submit, poll, cancel, queue depth (10) - BatchExecute async path, PollJob, QueueStatus integration (7) Co-Authored-By: Claude Opus 4.6 --- .../EditMode/Tools/BatchExecuteAsyncTests.cs | 61 ++++++++ .../EditMode/Tools/CommandClassifierTests.cs | 125 ++++++++++++++++ .../Tests/EditMode/Tools/CommandQueueTests.cs | 139 ++++++++++++++++++ .../EditMode/Tools/ExecutionTierTests.cs | 42 ++++++ .../Tests/EditMode/Tools/TicketStoreTests.cs | 122 +++++++++++++++ 5 files changed, 489 insertions(+) create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/BatchExecuteAsyncTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandClassifierTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandQueueTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ExecutionTierTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/TicketStoreTests.cs diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/BatchExecuteAsyncTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/BatchExecuteAsyncTests.cs new file mode 100644 index 000000000..31deb69ea --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/BatchExecuteAsyncTests.cs @@ -0,0 +1,61 @@ +using NUnit.Framework; +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Tools; + +namespace MCPForUnity.Tests.Editor +{ + [TestFixture] + public class BatchExecuteAsyncTests + { + [Test] + public void CommandGatewayState_Queue_IsNotNull() + { + Assert.That(CommandGatewayState.Queue, Is.Not.Null); + } + + [Test] + public void CommandGatewayState_Queue_IsSameInstance() + { + var q1 = CommandGatewayState.Queue; + var q2 = CommandGatewayState.Queue; + Assert.That(q1, Is.SameAs(q2)); + } + + [Test] + public void PollJob_NullParams_ReturnsError() + { + var result = PollJob.HandleCommand(null); + Assert.That(result, Is.Not.Null); + } + + [Test] + public void PollJob_MissingTicket_ReturnsError() + { + var result = PollJob.HandleCommand(new JObject()); + Assert.That(result, Is.Not.Null); + } + + [Test] + public void PollJob_InvalidTicket_ReturnsNotFound() + { + var p = new JObject { ["ticket"] = "nonexistent-ticket" }; + var result = PollJob.HandleCommand(p); + Assert.That(result, Is.Not.Null); + } + + [Test] + public void QueueStatus_ReturnsStatusObject() + { + var result = QueueStatus.HandleCommand(new JObject()); + Assert.That(result, Is.Not.Null); + } + + [Test] + public void BatchExecuteMaxCommands_DefaultIs25() + { + // Just verify the constants are sane + Assert.That(BatchExecute.DefaultMaxCommandsPerBatch, Is.EqualTo(25)); + Assert.That(BatchExecute.AbsoluteMaxCommandsPerBatch, Is.EqualTo(100)); + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandClassifierTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandClassifierTests.cs new file mode 100644 index 000000000..0957fda4f --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandClassifierTests.cs @@ -0,0 +1,125 @@ +using NUnit.Framework; +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Tools; + +namespace MCPForUnity.Tests.Editor +{ + [TestFixture] + public class CommandClassifierTests + { + [Test] + public void Classify_ManageScene_GetHierarchy_ReturnsInstant() + { + var p = new JObject { ["action"] = "get_hierarchy" }; + var tier = CommandClassifier.Classify("manage_scene", ExecutionTier.Smooth, p); + Assert.That(tier, Is.EqualTo(ExecutionTier.Instant)); + } + + [Test] + public void Classify_ManageScene_GetActive_ReturnsInstant() + { + var p = new JObject { ["action"] = "get_active" }; + var tier = CommandClassifier.Classify("manage_scene", ExecutionTier.Smooth, p); + Assert.That(tier, Is.EqualTo(ExecutionTier.Instant)); + } + + [Test] + public void Classify_ManageScene_Load_ReturnsHeavy() + { + var p = new JObject { ["action"] = "load" }; + var tier = CommandClassifier.Classify("manage_scene", ExecutionTier.Smooth, p); + Assert.That(tier, Is.EqualTo(ExecutionTier.Heavy)); + } + + [Test] + public void Classify_ManageScene_Save_ReturnsHeavy() + { + var p = new JObject { ["action"] = "save" }; + var tier = CommandClassifier.Classify("manage_scene", ExecutionTier.Smooth, p); + Assert.That(tier, Is.EqualTo(ExecutionTier.Heavy)); + } + + [Test] + public void Classify_RefreshUnity_CompileNone_ReturnsSmooth() + { + var p = new JObject { ["compile"] = "none" }; + var tier = CommandClassifier.Classify("refresh_unity", ExecutionTier.Heavy, p); + Assert.That(tier, Is.EqualTo(ExecutionTier.Smooth)); + } + + [Test] + public void Classify_RefreshUnity_CompileRequest_StaysHeavy() + { + var p = new JObject { ["compile"] = "request" }; + var tier = CommandClassifier.Classify("refresh_unity", ExecutionTier.Heavy, p); + Assert.That(tier, Is.EqualTo(ExecutionTier.Heavy)); + } + + [Test] + public void Classify_ManageEditor_TelemetryStatus_ReturnsInstant() + { + var p = new JObject { ["action"] = "telemetry_status" }; + var tier = CommandClassifier.Classify("manage_editor", ExecutionTier.Smooth, p); + Assert.That(tier, Is.EqualTo(ExecutionTier.Instant)); + } + + [Test] + public void Classify_ManageEditor_Play_ReturnsHeavy() + { + var p = new JObject { ["action"] = "play" }; + var tier = CommandClassifier.Classify("manage_editor", ExecutionTier.Smooth, p); + Assert.That(tier, Is.EqualTo(ExecutionTier.Heavy)); + } + + [Test] + public void Classify_UnknownTool_ReturnsAttributeTier() + { + var p = new JObject { ["action"] = "something" }; + var tier = CommandClassifier.Classify("unknown_tool", ExecutionTier.Smooth, p); + Assert.That(tier, Is.EqualTo(ExecutionTier.Smooth)); + } + + [Test] + public void Classify_NullParams_ReturnsAttributeTier() + { + var tier = CommandClassifier.Classify("manage_scene", ExecutionTier.Smooth, null); + Assert.That(tier, Is.EqualTo(ExecutionTier.Smooth)); + } + + [Test] + public void ClassifyBatch_AllInstant_ReturnsInstant() + { + var commands = new[] + { + ("find_gameobjects", ExecutionTier.Instant, new JObject()), + ("read_console", ExecutionTier.Instant, new JObject()) + }; + var tier = CommandClassifier.ClassifyBatch(commands); + Assert.That(tier, Is.EqualTo(ExecutionTier.Instant)); + } + + [Test] + public void ClassifyBatch_MixedWithHeavy_ReturnsHeavy() + { + var commands = new[] + { + ("find_gameobjects", ExecutionTier.Instant, new JObject()), + ("refresh_unity", ExecutionTier.Heavy, new JObject { ["compile"] = "request" }) + }; + var tier = CommandClassifier.ClassifyBatch(commands); + Assert.That(tier, Is.EqualTo(ExecutionTier.Heavy)); + } + + [Test] + public void ClassifyBatch_MixedSmoothAndInstant_ReturnsSmooth() + { + var commands = new[] + { + ("find_gameobjects", ExecutionTier.Instant, new JObject()), + ("manage_gameobject", ExecutionTier.Smooth, new JObject()) + }; + var tier = CommandClassifier.ClassifyBatch(commands); + Assert.That(tier, Is.EqualTo(ExecutionTier.Smooth)); + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandQueueTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandQueueTests.cs new file mode 100644 index 000000000..3c1751367 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandQueueTests.cs @@ -0,0 +1,139 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using NUnit.Framework; +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Tools; + +namespace MCPForUnity.Tests.Editor +{ + [TestFixture] + public class CommandQueueTests + { + CommandQueue _queue; + + static Task DummyExecutor(string tool, JObject p) + => Task.FromResult(new { success = true }); + + static Task SlowExecutor(string tool, JObject p) + => Task.FromResult(new { success = true }); + + [SetUp] + public void SetUp() => _queue = new CommandQueue(); + + [Test] + public void Submit_ReturnsJobWithTicket() + { + var cmds = new List + { + new() { Tool = "find_gameobjects", Params = new JObject(), Tier = ExecutionTier.Instant } + }; + var job = _queue.Submit("agent-1", "test", false, cmds); + Assert.That(job, Is.Not.Null); + Assert.That(job.Ticket, Does.StartWith("t-")); + } + + [Test] + public void Submit_HeavyJob_IncreasesQueueDepth() + { + var cmds = new List + { + new() { Tool = "refresh_unity", Params = new JObject(), Tier = ExecutionTier.Heavy } + }; + _queue.Submit("agent-1", "heavy", false, cmds); + Assert.That(_queue.QueueDepth, Is.GreaterThanOrEqualTo(1)); + } + + [Test] + public void Poll_ExistingTicket_ReturnsJob() + { + var cmds = new List + { + new() { Tool = "find_gameobjects", Params = new JObject(), Tier = ExecutionTier.Instant } + }; + var job = _queue.Submit("agent-1", "test", false, cmds); + var polled = _queue.Poll(job.Ticket); + Assert.That(polled, Is.Not.Null); + Assert.That(polled.Ticket, Is.EqualTo(job.Ticket)); + } + + [Test] + public void Poll_InvalidTicket_ReturnsNull() + { + Assert.That(_queue.Poll("nonexistent"), Is.Null); + } + + [Test] + public void Cancel_QueuedJob_Succeeds() + { + var cmds = new List + { + new() { Tool = "refresh_unity", Params = new JObject(), Tier = ExecutionTier.Heavy } + }; + var job = _queue.Submit("agent-1", "heavy", false, cmds); + bool cancelled = _queue.Cancel(job.Ticket, "agent-1"); + Assert.That(cancelled, Is.True); + Assert.That(job.Status, Is.EqualTo(JobStatus.Cancelled)); + } + + [Test] + public void Cancel_WrongAgent_Fails() + { + var cmds = new List + { + new() { Tool = "refresh_unity", Params = new JObject(), Tier = ExecutionTier.Heavy } + }; + var job = _queue.Submit("agent-1", "heavy", false, cmds); + bool cancelled = _queue.Cancel(job.Ticket, "agent-2"); + Assert.That(cancelled, Is.False); + Assert.That(job.Status, Is.EqualTo(JobStatus.Queued)); + } + + [Test] + public void GetAheadOf_ReturnsJobsBeforeTicket() + { + var heavyCmds = new List + { + new() { Tool = "refresh_unity", Params = new JObject(), Tier = ExecutionTier.Heavy } + }; + var j1 = _queue.Submit("a", "first", false, heavyCmds); + var j2 = _queue.Submit("b", "second", false, heavyCmds); + + var ahead = _queue.GetAheadOf(j2.Ticket); + Assert.That(ahead.Count, Is.GreaterThanOrEqualTo(1)); + Assert.That(ahead[0].Ticket, Is.EqualTo(j1.Ticket)); + } + + [Test] + public void HasActiveHeavy_InitiallyFalse() + { + Assert.That(_queue.HasActiveHeavy, Is.False); + } + + [Test] + public void GetStatus_ReturnsQueueInfo() + { + var cmds = new List + { + new() { Tool = "find_gameobjects", Params = new JObject(), Tier = ExecutionTier.Instant } + }; + _queue.Submit("agent-1", "test", false, cmds); + var status = _queue.GetStatus(); + Assert.That(status, Is.Not.Null); + } + + [Test] + public void Submit_MultipleHeavyJobs_AllQueued() + { + var heavyCmds = new List + { + new() { Tool = "refresh_unity", Params = new JObject(), Tier = ExecutionTier.Heavy } + }; + _queue.Submit("a", "h1", false, heavyCmds); + _queue.Submit("b", "h2", false, heavyCmds); + _queue.Submit("c", "h3", false, heavyCmds); + + // All three should be queued + Assert.That(_queue.QueueDepth, Is.GreaterThanOrEqualTo(3)); + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ExecutionTierTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ExecutionTierTests.cs new file mode 100644 index 000000000..9a636439c --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ExecutionTierTests.cs @@ -0,0 +1,42 @@ +using NUnit.Framework; +using MCPForUnity.Editor.Tools; + +namespace MCPForUnity.Tests.Editor +{ + [TestFixture] + public class ExecutionTierTests + { + [Test] + public void ExecutionTier_DefaultValue_IsSmooth() + { + var attr = new McpForUnityToolAttribute("test_tool"); + Assert.That(attr.Tier, Is.EqualTo(ExecutionTier.Smooth)); + } + + [Test] + public void ExecutionTier_CanSetToHeavy() + { + var attr = new McpForUnityToolAttribute("test_tool") { Tier = ExecutionTier.Heavy }; + Assert.That(attr.Tier, Is.EqualTo(ExecutionTier.Heavy)); + } + + [Test] + public void ExecutionTier_CanSetToInstant() + { + var attr = new McpForUnityToolAttribute("test_tool") { Tier = ExecutionTier.Instant }; + Assert.That(attr.Tier, Is.EqualTo(ExecutionTier.Instant)); + } + + [Test] + public void ExecutionTier_Ordering_InstantLessThanSmooth() + { + Assert.That(ExecutionTier.Instant, Is.LessThan(ExecutionTier.Smooth)); + } + + [Test] + public void ExecutionTier_Ordering_SmoothLessThanHeavy() + { + Assert.That(ExecutionTier.Smooth, Is.LessThan(ExecutionTier.Heavy)); + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/TicketStoreTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/TicketStoreTests.cs new file mode 100644 index 000000000..d1b7261e4 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/TicketStoreTests.cs @@ -0,0 +1,122 @@ +using NUnit.Framework; +using MCPForUnity.Editor.Tools; + +namespace MCPForUnity.Tests.Editor +{ + [TestFixture] + public class TicketStoreTests + { + TicketStore _store; + + [SetUp] + public void SetUp() => _store = new TicketStore(); + + [Test] + public void CreateJob_ReturnsUniqueTicket() + { + var job1 = _store.CreateJob("agent-1", "label-1", true, ExecutionTier.Heavy); + var job2 = _store.CreateJob("agent-2", "label-2", false, ExecutionTier.Smooth); + Assert.That(job1.Ticket, Is.Not.EqualTo(job2.Ticket)); + } + + [Test] + public void CreateJob_DefaultStatus_IsQueued() + { + var job = _store.CreateJob("agent-1", "test", false, ExecutionTier.Smooth); + Assert.That(job.Status, Is.EqualTo(JobStatus.Queued)); + } + + [Test] + public void CreateJob_StoresAgent() + { + var job = _store.CreateJob("my-agent", "test", false, ExecutionTier.Smooth); + Assert.That(job.Agent, Is.EqualTo("my-agent")); + } + + [Test] + public void CreateJob_NullAgent_DefaultsToAnonymous() + { + var job = _store.CreateJob(null, "test", false, ExecutionTier.Smooth); + Assert.That(job.Agent, Is.EqualTo("anonymous")); + } + + [Test] + public void GetJob_ValidTicket_ReturnsJob() + { + var job = _store.CreateJob("agent-1", "test", false, ExecutionTier.Smooth); + var found = _store.GetJob(job.Ticket); + Assert.That(found, Is.Not.Null); + Assert.That(found.Ticket, Is.EqualTo(job.Ticket)); + } + + [Test] + public void GetJob_InvalidTicket_ReturnsNull() + { + Assert.That(_store.GetJob("nonexistent"), Is.Null); + } + + [Test] + public void CleanExpired_RemovesDoneJobsOlderThanTimeout() + { + var job = _store.CreateJob("agent-1", "test", false, ExecutionTier.Smooth); + job.Status = JobStatus.Done; + job.CompletedAt = System.DateTime.UtcNow.AddMinutes(-6); + _store.CleanExpired(System.TimeSpan.FromMinutes(5)); + Assert.That(_store.GetJob(job.Ticket), Is.Null); + } + + [Test] + public void CleanExpired_KeepsRecentDoneJobs() + { + var job = _store.CreateJob("agent-1", "test", false, ExecutionTier.Smooth); + job.Status = JobStatus.Done; + job.CompletedAt = System.DateTime.UtcNow; + _store.CleanExpired(System.TimeSpan.FromMinutes(5)); + Assert.That(_store.GetJob(job.Ticket), Is.Not.Null); + } + + [Test] + public void CleanExpired_KeepsQueuedJobs() + { + var job = _store.CreateJob("agent-1", "test", false, ExecutionTier.Smooth); + // Queued jobs have no CompletedAt — should not be cleaned + _store.CleanExpired(System.TimeSpan.FromMinutes(5)); + Assert.That(_store.GetJob(job.Ticket), Is.Not.Null); + } + + [Test] + public void GetAgentStats_ReturnsCorrectCounts() + { + _store.CreateJob("agent-1", "a", false, ExecutionTier.Smooth); + var done = _store.CreateJob("agent-1", "b", false, ExecutionTier.Smooth); + done.Status = JobStatus.Done; + _store.CreateJob("agent-2", "c", false, ExecutionTier.Heavy); + + var stats = _store.GetAgentStats(); + Assert.That(stats["agent-1"].Queued, Is.EqualTo(1)); + Assert.That(stats["agent-1"].Completed, Is.EqualTo(1)); + Assert.That(stats["agent-2"].Queued, Is.EqualTo(1)); + } + + [Test] + public void QueueDepth_ReflectsQueuedJobsOnly() + { + _store.CreateJob("a", "j1", false, ExecutionTier.Smooth); + _store.CreateJob("b", "j2", false, ExecutionTier.Heavy); + var done = _store.CreateJob("c", "j3", false, ExecutionTier.Instant); + done.Status = JobStatus.Done; + + Assert.That(_store.QueueDepth, Is.EqualTo(2)); + } + + [Test] + public void GetQueuedJobs_OrderedByCreationTime() + { + var j1 = _store.CreateJob("a", "first", false, ExecutionTier.Heavy); + var j2 = _store.CreateJob("b", "second", false, ExecutionTier.Smooth); + var queued = _store.GetQueuedJobs(); + Assert.That(queued[0].Ticket, Is.EqualTo(j1.Ticket)); + Assert.That(queued[1].Ticket, Is.EqualTo(j2.Ticket)); + } + } +} From 504bdcfa419bb5ff3c4a92c3f42303fb307b6a7b Mon Sep 17 00:00:00 2001 From: lior Date: Wed, 25 Feb 2026 10:55:35 +0200 Subject: [PATCH 08/33] fix(tools): add missing using directive for IMcpResponse in CommandQueue Co-Authored-By: Claude Opus 4.6 --- MCPForUnity/Editor/Tools/CommandQueue.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/MCPForUnity/Editor/Tools/CommandQueue.cs b/MCPForUnity/Editor/Tools/CommandQueue.cs index 382a38a36..43710f5ee 100644 --- a/MCPForUnity/Editor/Tools/CommandQueue.cs +++ b/MCPForUnity/Editor/Tools/CommandQueue.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using MCPForUnity.Editor.Helpers; using Newtonsoft.Json.Linq; using UnityEditor; From 15cd6c88fa9118d4bb1354aa7fbd513c25bbfa6b Mon Sep 17 00:00:00 2001 From: lior Date: Wed, 25 Feb 2026 11:11:27 +0200 Subject: [PATCH 09/33] chore(tools): add Unity meta files for command gateway scripts Co-Authored-By: Claude Opus 4.6 --- MCPForUnity/Editor/Tools/BatchJob.cs.meta | 2 ++ MCPForUnity/Editor/Tools/CommandClassifier.cs.meta | 2 ++ MCPForUnity/Editor/Tools/CommandGatewayState.cs.meta | 2 ++ MCPForUnity/Editor/Tools/CommandQueue.cs.meta | 2 ++ MCPForUnity/Editor/Tools/ExecutionTier.cs.meta | 2 ++ MCPForUnity/Editor/Tools/PollJob.cs.meta | 2 ++ MCPForUnity/Editor/Tools/QueueStatus.cs.meta | 2 ++ MCPForUnity/Editor/Tools/TicketStore.cs.meta | 2 ++ 8 files changed, 16 insertions(+) create mode 100644 MCPForUnity/Editor/Tools/BatchJob.cs.meta create mode 100644 MCPForUnity/Editor/Tools/CommandClassifier.cs.meta create mode 100644 MCPForUnity/Editor/Tools/CommandGatewayState.cs.meta create mode 100644 MCPForUnity/Editor/Tools/CommandQueue.cs.meta create mode 100644 MCPForUnity/Editor/Tools/ExecutionTier.cs.meta create mode 100644 MCPForUnity/Editor/Tools/PollJob.cs.meta create mode 100644 MCPForUnity/Editor/Tools/QueueStatus.cs.meta create mode 100644 MCPForUnity/Editor/Tools/TicketStore.cs.meta diff --git a/MCPForUnity/Editor/Tools/BatchJob.cs.meta b/MCPForUnity/Editor/Tools/BatchJob.cs.meta new file mode 100644 index 000000000..d7de5b88d --- /dev/null +++ b/MCPForUnity/Editor/Tools/BatchJob.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 55aec8a0199deab4892946b284bc96d9 \ No newline at end of file diff --git a/MCPForUnity/Editor/Tools/CommandClassifier.cs.meta b/MCPForUnity/Editor/Tools/CommandClassifier.cs.meta new file mode 100644 index 000000000..709500338 --- /dev/null +++ b/MCPForUnity/Editor/Tools/CommandClassifier.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e8217c58008fd144e9ce35bc32acdb1c \ No newline at end of file diff --git a/MCPForUnity/Editor/Tools/CommandGatewayState.cs.meta b/MCPForUnity/Editor/Tools/CommandGatewayState.cs.meta new file mode 100644 index 000000000..da9d86109 --- /dev/null +++ b/MCPForUnity/Editor/Tools/CommandGatewayState.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9f8e034942012d44397994c60cceea3e \ No newline at end of file diff --git a/MCPForUnity/Editor/Tools/CommandQueue.cs.meta b/MCPForUnity/Editor/Tools/CommandQueue.cs.meta new file mode 100644 index 000000000..3608a967b --- /dev/null +++ b/MCPForUnity/Editor/Tools/CommandQueue.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 54e9ce4f1c68f4e4786b29af3f07e396 \ No newline at end of file diff --git a/MCPForUnity/Editor/Tools/ExecutionTier.cs.meta b/MCPForUnity/Editor/Tools/ExecutionTier.cs.meta new file mode 100644 index 000000000..d8b6badad --- /dev/null +++ b/MCPForUnity/Editor/Tools/ExecutionTier.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 07b50a6c5e7342f4089b4fc25598f102 \ No newline at end of file diff --git a/MCPForUnity/Editor/Tools/PollJob.cs.meta b/MCPForUnity/Editor/Tools/PollJob.cs.meta new file mode 100644 index 000000000..77b1d7ecb --- /dev/null +++ b/MCPForUnity/Editor/Tools/PollJob.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d6a89ed4b0341b741813cd27b4e76d6f \ No newline at end of file diff --git a/MCPForUnity/Editor/Tools/QueueStatus.cs.meta b/MCPForUnity/Editor/Tools/QueueStatus.cs.meta new file mode 100644 index 000000000..b212457f9 --- /dev/null +++ b/MCPForUnity/Editor/Tools/QueueStatus.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e4ae3fb030d56ea4d8291e2655061c3d \ No newline at end of file diff --git a/MCPForUnity/Editor/Tools/TicketStore.cs.meta b/MCPForUnity/Editor/Tools/TicketStore.cs.meta new file mode 100644 index 000000000..fa28cd1ce --- /dev/null +++ b/MCPForUnity/Editor/Tools/TicketStore.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bfc8e42fd64975a41a1fa593e0b57b40 \ No newline at end of file From 255ef84686d9b05ad1c53ea64c558582981cfec5 Mon Sep 17 00:00:00 2001 From: lior Date: Wed, 25 Feb 2026 11:38:40 +0200 Subject: [PATCH 10/33] docs: add gateway async-aware blocking design Design for two fixes to the command gateway: 1. Domain-reload-causing operations (refresh_unity, play mode) are held in queue while tests are running or compilation is active 2. Queue state persists across domain reloads via SessionState Co-Authored-By: Claude Opus 4.6 --- ...26-02-25-gateway-async-awareness-design.md | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 docs/plans/2026-02-25-gateway-async-awareness-design.md diff --git a/docs/plans/2026-02-25-gateway-async-awareness-design.md b/docs/plans/2026-02-25-gateway-async-awareness-design.md new file mode 100644 index 000000000..05364878f --- /dev/null +++ b/docs/plans/2026-02-25-gateway-async-awareness-design.md @@ -0,0 +1,112 @@ +# Gateway Async-Aware Blocking + State Persistence Design + +**Goal:** Make the command gateway aware of async Unity operations (test runs, compilation) so domain-reload-causing commands are held until safe, and queue state survives domain reloads. + +**Context:** The gateway's `CommandQueue` uses in-memory static state that gets wiped on domain reload. Operations like `refresh_unity` trigger reload, destroying all ticket/queue data. Meanwhile, `run_tests` returns instantly (just submits to Unity's TestRunner) so the gateway treats it as complete even though tests are still executing. A subsequent `refresh_unity` would corrupt the test run. + +--- + +## Problem 1: Async-Aware Blocking + +### Classification + +Extend `CommandClassifier.Classify` to return a `causesDomainReload` flag alongside the existing `ExecutionTier`. + +**Domain-reload operations (only two):** +- `refresh_unity` when `compile != "none"` +- `manage_editor` when `action == "play"` + +**Heavy but NOT domain-reload:** `run_tests`, `manage_script` (create/delete is file I/O only), `manage_shader`, `manage_scene` (load/save), `manage_editor` (stop). + +Script and shader file operations are disk writes. Domain reload happens only when `refresh_unity` is explicitly called. This allows batching multiple script creates before a single refresh. + +### Guard Logic + +In `CommandQueue.ProcessTick`, before dequeuing a heavy job: + +``` +if job.CausesDomainReload + AND (TestJobManager.HasRunningJob OR EditorApplication.isCompiling) +→ skip, keep queued +``` + +Non-reload heavy jobs proceed normally. The guard is a predicate added to the existing dequeue check, not a restructure. + +### Caller Feedback + +`poll_job` response includes `blocked_by` reason when a job is held: + +```json +{ + "status": "queued", + "blocked_by": "tests_running", + "position": 0 +} +``` + +--- + +## Problem 2: State Persistence + +### Mechanism + +Follow the `TestJobManager` pattern: serialize queue state to `SessionState` (survives domain reloads within a single editor session). + +**Persisted:** +- Active tickets + status (queued/running/done/failed) +- Agent, label, tier, `causesDomainReload` flag per job +- Commands in each job (tool name + serialized params) +- `_nextId` counter + +**Not persisted:** +- Results (consumed once, then expire) +- Smooth/Instant in-flight tracking (ephemeral) + +### Lifecycle + +- `CommandGatewayState` hooks `AssemblyReloadEvents.beforeAssemblyReload` → serialize to `SessionState` +- `[InitializeOnLoad]` constructor → restore from `SessionState` +- Heavy jobs that were `Running` when reload hit → mark `Failed` ("interrupted by domain reload") + +**Key:** `SessionState.SetString("MCPForUnity.GatewayQueueV1", json)` + +--- + +## Files Modified + +| File | Change | +|------|--------| +| `CommandClassifier.cs` | Return `(ExecutionTier, bool causesDomainReload)` tuple | +| `BatchCommand.cs` (in BatchJob.cs) | Add `bool CausesDomainReload` property | +| `BatchJob.cs` | Add `bool CausesDomainReload` (true if any command causes reload) | +| `CommandQueue.cs` | Add guard in `ProcessTick`, add serialization methods | +| `CommandGatewayState.cs` | Hook `beforeAssemblyReload`, restore on init | +| `TicketStore.cs` | Add JSON serialization/deserialization | +| `BatchExecute.cs` | Propagate `causesDomainReload` from classifier | +| `PollJob.cs` | Include `blocked_by` in queued response | + +## Files Added + +| File | Purpose | +|------|---------| +| Tests: `CommandClassifierTests.cs` | Domain-reload classification tests | +| Tests: `CommandQueuePersistenceTests.cs` | SessionState round-trip tests | +| Tests: `CommandQueueGuardTests.cs` | Guard logic: blocked when tests running, proceeds when clear | + +--- + +## Test Plan + +**Unit tests:** +- Classifier returns `causesDomainReload=true` for `refresh_unity` (compile=request), `manage_editor` (play) +- Classifier returns `causesDomainReload=false` for `run_tests`, `manage_script` (create), `manage_scene` (load) +- Queue skips domain-reload job when `TestJobManager.HasRunningJob` is true +- Queue proceeds with domain-reload job when tests are not running +- TicketStore JSON round-trip preserves tickets, status, counter +- Interrupted Running jobs marked Failed on restore + +**Integration test (manual via MCP):** +1. Submit async batch: `run_tests` → gets ticket, starts +2. Submit async batch: `refresh_unity` (compile=request) → gets ticket, held +3. Poll second ticket → "queued", `blocked_by: "tests_running"` +4. After tests complete → second job proceeds From 7d93dcc2093f06989720427317f37a812b29b36a Mon Sep 17 00:00:00 2001 From: lior Date: Wed, 25 Feb 2026 11:43:07 +0200 Subject: [PATCH 11/33] docs: add gateway async-awareness implementation plan 7-task TDD plan for making the command gateway aware of async Unity operations (test runs, compilation) so domain-reload-causing commands are held until safe, and queue state survives domain reloads. Co-Authored-By: Claude Opus 4.6 --- ...2026-02-25-gateway-async-awareness-plan.md | 937 ++++++++++++++++++ 1 file changed, 937 insertions(+) create mode 100644 docs/plans/2026-02-25-gateway-async-awareness-plan.md diff --git a/docs/plans/2026-02-25-gateway-async-awareness-plan.md b/docs/plans/2026-02-25-gateway-async-awareness-plan.md new file mode 100644 index 000000000..b0f5f223d --- /dev/null +++ b/docs/plans/2026-02-25-gateway-async-awareness-plan.md @@ -0,0 +1,937 @@ +# Gateway Async-Aware Blocking Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Make the command gateway hold domain-reload operations while tests run, and persist queue state across domain reloads. + +**Architecture:** Extend `CommandClassifier` to tag commands with `CausesDomainReload`. Add a guard predicate to `CommandQueue.ProcessTick` that blocks reload-causing jobs when `TestJobManager.HasRunningJob` or `EditorApplication.isCompiling`. Persist queue state to `SessionState` following the existing `TestJobManager` pattern. + +**Tech Stack:** Unity 6 Editor C# (NUnit, SessionState, Newtonsoft.Json, EditorApplication, AssemblyReloadEvents) + +--- + +### Task 1: Add `CausesDomainReload` to CommandClassifier + +**Files:** +- Modify: `MCPForUnity/Editor/Tools/CommandClassifier.cs` +- Modify: `TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandClassifierTests.cs` + +**Step 1: Write the failing tests** + +Add these tests to the bottom of the existing `CommandClassifierTests` class in `TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandClassifierTests.cs`: + +```csharp +[Test] +public void CausesDomainReload_RefreshUnity_CompileRequest_ReturnsTrue() +{ + var p = new JObject { ["compile"] = "request" }; + Assert.That(CommandClassifier.CausesDomainReload("refresh_unity", p), Is.True); +} + +[Test] +public void CausesDomainReload_RefreshUnity_CompileNone_ReturnsFalse() +{ + var p = new JObject { ["compile"] = "none" }; + Assert.That(CommandClassifier.CausesDomainReload("refresh_unity", p), Is.False); +} + +[Test] +public void CausesDomainReload_RefreshUnity_NoCompileParam_ReturnsTrue() +{ + var p = new JObject { ["scope"] = "all" }; + Assert.That(CommandClassifier.CausesDomainReload("refresh_unity", p), Is.True); +} + +[Test] +public void CausesDomainReload_ManageEditor_Play_ReturnsTrue() +{ + var p = new JObject { ["action"] = "play" }; + Assert.That(CommandClassifier.CausesDomainReload("manage_editor", p), Is.True); +} + +[Test] +public void CausesDomainReload_ManageEditor_Stop_ReturnsFalse() +{ + var p = new JObject { ["action"] = "stop" }; + Assert.That(CommandClassifier.CausesDomainReload("manage_editor", p), Is.False); +} + +[Test] +public void CausesDomainReload_RunTests_ReturnsFalse() +{ + var p = new JObject { ["mode"] = "EditMode" }; + Assert.That(CommandClassifier.CausesDomainReload("run_tests", p), Is.False); +} + +[Test] +public void CausesDomainReload_ManageScript_Create_ReturnsFalse() +{ + var p = new JObject { ["action"] = "create" }; + Assert.That(CommandClassifier.CausesDomainReload("manage_script", p), Is.False); +} + +[Test] +public void CausesDomainReload_ManageScene_Load_ReturnsFalse() +{ + var p = new JObject { ["action"] = "load" }; + Assert.That(CommandClassifier.CausesDomainReload("manage_scene", p), Is.False); +} + +[Test] +public void CausesDomainReload_NullParams_ReturnsFalse() +{ + Assert.That(CommandClassifier.CausesDomainReload("refresh_unity", null), Is.False); +} +``` + +**Step 2: Run tests to verify they fail** + +Run via Unity MCP: +``` +run_tests(mode=EditMode, test_names=[ + "MCPForUnity.Tests.Editor.CommandClassifierTests.CausesDomainReload_RefreshUnity_CompileRequest_ReturnsTrue" +]) +``` +Expected: compile error — `CausesDomainReload` method doesn't exist yet. + +**Step 3: Implement `CausesDomainReload` method** + +Add this method to `CommandClassifier` in `MCPForUnity/Editor/Tools/CommandClassifier.cs` after the existing `Classify` method (after line 28): + +```csharp +/// +/// Returns true if the given command would trigger a domain reload (compilation or play mode entry). +/// +public static bool CausesDomainReload(string toolName, JObject @params) +{ + if (@params == null) return false; + + return toolName switch + { + "refresh_unity" => @params.Value("compile") != "none", + "manage_editor" => @params.Value("action") == "play", + _ => false + }; +} +``` + +**Step 4: Run tests to verify they pass** + +Run via Unity MCP: +``` +run_tests(mode=EditMode, assembly_names=["MCPForUnity.Tests.Editor"]) +``` +Expected: all `CausesDomainReload_*` tests PASS, existing tests still PASS. + +**Step 5: Commit** + +```bash +cd /home/liory/Github/unity-mcp-fork +git add MCPForUnity/Editor/Tools/CommandClassifier.cs TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandClassifierTests.cs +git commit -m "feat(tools): add CausesDomainReload to CommandClassifier + +Only refresh_unity (compile!=none) and manage_editor (play) trigger +domain reload. Script/shader file ops are just disk I/O." +``` + +--- + +### Task 2: Add `CausesDomainReload` to BatchCommand and BatchJob + +**Files:** +- Modify: `MCPForUnity/Editor/Tools/BatchJob.cs` + +**Step 1: Write the failing test** + +Add to `TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandQueueTests.cs`: + +```csharp +[Test] +public void BatchCommand_CausesDomainReload_DefaultsFalse() +{ + var cmd = new BatchCommand { Tool = "find_gameobjects", Params = new JObject(), Tier = ExecutionTier.Instant }; + Assert.That(cmd.CausesDomainReload, Is.False); +} + +[Test] +public void BatchJob_CausesDomainReload_DefaultsFalse() +{ + var job = new BatchJob(); + Assert.That(job.CausesDomainReload, Is.False); +} +``` + +**Step 2: Run tests to verify they fail** + +Expected: compile error — `CausesDomainReload` property doesn't exist. + +**Step 3: Add the properties** + +In `MCPForUnity/Editor/Tools/BatchJob.cs`, add to `BatchCommand` class (after line 36, `public ExecutionTier Tier`): + +```csharp +public bool CausesDomainReload { get; set; } +``` + +Add to `BatchJob` class (after line 18, `public ExecutionTier Tier`): + +```csharp +public bool CausesDomainReload { get; set; } +``` + +**Step 4: Run tests to verify they pass** + +Expected: both new tests PASS. + +**Step 5: Commit** + +```bash +cd /home/liory/Github/unity-mcp-fork +git add MCPForUnity/Editor/Tools/BatchJob.cs TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandQueueTests.cs +git commit -m "feat(tools): add CausesDomainReload property to BatchCommand and BatchJob" +``` + +--- + +### Task 3: Propagate `CausesDomainReload` in BatchExecute and CommandQueue + +**Files:** +- Modify: `MCPForUnity/Editor/Tools/BatchExecute.cs:254-268` +- Modify: `MCPForUnity/Editor/Tools/CommandQueue.cs:31-44` + +**Step 1: Write the failing test** + +Add to `CommandQueueTests.cs`: + +```csharp +[Test] +public void Submit_WithReloadCommand_SetsJobCausesDomainReload() +{ + var cmds = new List + { + new() { Tool = "find_gameobjects", Params = new JObject(), Tier = ExecutionTier.Instant, CausesDomainReload = false }, + new() { Tool = "refresh_unity", Params = new JObject(), Tier = ExecutionTier.Heavy, CausesDomainReload = true } + }; + var job = _queue.Submit("agent-1", "test", false, cmds); + Assert.That(job.CausesDomainReload, Is.True); +} + +[Test] +public void Submit_WithoutReloadCommand_JobCausesDomainReloadFalse() +{ + var cmds = new List + { + new() { Tool = "run_tests", Params = new JObject(), Tier = ExecutionTier.Heavy, CausesDomainReload = false } + }; + var job = _queue.Submit("agent-1", "test", false, cmds); + Assert.That(job.CausesDomainReload, Is.False); +} +``` + +**Step 2: Run tests to verify they fail** + +Expected: `Submit_WithReloadCommand_SetsJobCausesDomainReload` fails — `job.CausesDomainReload` is `false`. + +**Step 3: Implement propagation** + +In `MCPForUnity/Editor/Tools/CommandQueue.cs`, in the `Submit` method, after line 37 (`job.Commands = commands;`), add: + +```csharp +job.CausesDomainReload = commands.Any(c => c.CausesDomainReload); +``` + +In `MCPForUnity/Editor/Tools/BatchExecute.cs`, in `HandleAsyncSubmit`, replace line 267: + +```csharp +commands.Add(new BatchCommand { Tool = toolName, Params = cmdParams, Tier = effectiveTier }); +``` + +with: + +```csharp +commands.Add(new BatchCommand +{ + Tool = toolName, + Params = cmdParams, + Tier = effectiveTier, + CausesDomainReload = CommandClassifier.CausesDomainReload(toolName, cmdParams) +}); +``` + +**Step 4: Run tests to verify they pass** + +Expected: all new and existing tests PASS. + +**Step 5: Commit** + +```bash +cd /home/liory/Github/unity-mcp-fork +git add MCPForUnity/Editor/Tools/CommandQueue.cs MCPForUnity/Editor/Tools/BatchExecute.cs TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandQueueTests.cs +git commit -m "feat(tools): propagate CausesDomainReload through queue submission" +``` + +--- + +### Task 4: Add guard logic to ProcessTick + +**Files:** +- Modify: `MCPForUnity/Editor/Tools/CommandQueue.cs` +- Modify: `MCPForUnity/Editor/Tools/CommandGatewayState.cs` +- Modify: `TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandQueueTests.cs` + +**Step 1: Write the failing tests** + +Add to `CommandQueueTests.cs`: + +```csharp +[Test] +public void ProcessTick_ReloadJob_SkippedWhenEditorBusy() +{ + _queue.IsEditorBusy = () => true; + var cmds = new List + { + new() { Tool = "refresh_unity", Params = new JObject(), Tier = ExecutionTier.Heavy, CausesDomainReload = true } + }; + var job = _queue.Submit("agent-1", "refresh", false, cmds); + + // Tick should NOT start the job because editor is busy + _queue.ProcessTick(DummyExecutor); + Assert.That(job.Status, Is.EqualTo(JobStatus.Queued)); + Assert.That(_queue.HasActiveHeavy, Is.False); +} + +[Test] +public void ProcessTick_ReloadJob_ProceedsWhenEditorNotBusy() +{ + _queue.IsEditorBusy = () => false; + var cmds = new List + { + new() { Tool = "refresh_unity", Params = new JObject(), Tier = ExecutionTier.Heavy, CausesDomainReload = true } + }; + var job = _queue.Submit("agent-1", "refresh", false, cmds); + + _queue.ProcessTick(DummyExecutor); + Assert.That(job.Status, Is.Not.EqualTo(JobStatus.Queued)); +} + +[Test] +public void ProcessTick_NonReloadHeavyJob_ProceedsEvenWhenBusy() +{ + _queue.IsEditorBusy = () => true; + var cmds = new List + { + new() { Tool = "run_tests", Params = new JObject(), Tier = ExecutionTier.Heavy, CausesDomainReload = false } + }; + var job = _queue.Submit("agent-1", "tests", false, cmds); + + _queue.ProcessTick(DummyExecutor); + Assert.That(job.Status, Is.Not.EqualTo(JobStatus.Queued)); +} + +[Test] +public void ProcessTick_ReloadJobBehindNonReload_NonReloadProceeds() +{ + _queue.IsEditorBusy = () => true; + var testCmds = new List + { + new() { Tool = "run_tests", Params = new JObject(), Tier = ExecutionTier.Heavy, CausesDomainReload = false } + }; + var refreshCmds = new List + { + new() { Tool = "refresh_unity", Params = new JObject(), Tier = ExecutionTier.Heavy, CausesDomainReload = true } + }; + var testJob = _queue.Submit("agent-1", "tests", false, testCmds); + var refreshJob = _queue.Submit("agent-2", "refresh", false, refreshCmds); + + _queue.ProcessTick(DummyExecutor); + // test job should start (non-reload), refresh should stay queued + Assert.That(testJob.Status, Is.Not.EqualTo(JobStatus.Queued)); + Assert.That(refreshJob.Status, Is.EqualTo(JobStatus.Queued)); +} +``` + +**Step 2: Run tests to verify they fail** + +Expected: compile error — `IsEditorBusy` property doesn't exist on `CommandQueue`. + +**Step 3: Implement the guard** + +In `MCPForUnity/Editor/Tools/CommandQueue.cs`: + +Add after line 22 (`static readonly TimeSpan TicketExpiry`): + +```csharp +/// +/// Predicate that returns true when domain-reload operations should be deferred. +/// Default always returns false. CommandGatewayState wires this to the real checks. +/// +public Func IsEditorBusy { get; set; } = () => false; +``` + +Replace the heavy queue dequeue block (lines 132-144) with: + +```csharp +// 2. If heavy queue has items and no smooth in flight, start next heavy +if (_heavyQueue.Count > 0 && _smoothInFlight.Count == 0) +{ + bool editorBusy = IsEditorBusy(); + // Peek-and-skip: find the first eligible heavy job + int count = _heavyQueue.Count; + for (int i = 0; i < count; i++) + { + var ticket = _heavyQueue.Dequeue(); + var job = _store.GetJob(ticket); + if (job == null || job.Status == JobStatus.Cancelled) continue; + + // Guard: domain-reload jobs must wait when editor is busy + if (job.CausesDomainReload && editorBusy) + { + _heavyQueue.Enqueue(ticket); // put back at end + continue; + } + + _activeHeavyTicket = ticket; + _ = ExecuteJob(job, executeCommand); + // Re-enqueue any remaining peeked items were already handled by the loop + return; + } +} +``` + +In `MCPForUnity/Editor/Tools/CommandGatewayState.cs`, wire the real predicate. Replace the static constructor (lines 14-17): + +```csharp +static CommandGatewayState() +{ + Queue.IsEditorBusy = () => + MCPForUnity.Editor.Services.TestJobManager.HasRunningJob + || UnityEditor.EditorApplication.isCompiling; + EditorApplication.update += OnUpdate; +} +``` + +Add `using MCPForUnity.Editor.Services;` at the top of `CommandGatewayState.cs`. + +**Step 4: Run tests to verify they pass** + +Expected: all 4 new guard tests PASS, all existing tests PASS. + +**Step 5: Commit** + +```bash +cd /home/liory/Github/unity-mcp-fork +git add MCPForUnity/Editor/Tools/CommandQueue.cs MCPForUnity/Editor/Tools/CommandGatewayState.cs TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandQueueTests.cs +git commit -m "feat(tools): add domain-reload guard to ProcessTick + +Domain-reload-causing jobs (refresh_unity compile, manage_editor play) +are held in queue while TestJobManager.HasRunningJob or +EditorApplication.isCompiling is true. Non-reload heavy jobs proceed +normally." +``` + +--- + +### Task 5: Add `blocked_by` to PollJob response + +**Files:** +- Modify: `MCPForUnity/Editor/Tools/PollJob.cs` +- Modify: `MCPForUnity/Editor/Tools/CommandQueue.cs` +- Modify: `TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/BatchExecuteAsyncTests.cs` + +**Step 1: Write the failing test** + +Add to `BatchExecuteAsyncTests.cs`: + +```csharp +[Test] +public void PollJob_QueuedReloadJob_IncludesBlockedBy_WhenBusy() +{ + var queue = new CommandQueue(); + queue.IsEditorBusy = () => true; + var cmds = new List + { + new() { Tool = "refresh_unity", Params = new JObject(), Tier = ExecutionTier.Heavy, CausesDomainReload = true } + }; + var job = queue.Submit("agent-1", "refresh", false, cmds); + + string reason = queue.GetBlockedReason(job.Ticket); + Assert.That(reason, Is.Not.Null); +} + +[Test] +public void PollJob_QueuedNonReloadJob_BlockedByIsNull() +{ + var queue = new CommandQueue(); + queue.IsEditorBusy = () => true; + var cmds = new List + { + new() { Tool = "run_tests", Params = new JObject(), Tier = ExecutionTier.Heavy, CausesDomainReload = false } + }; + var job = queue.Submit("agent-1", "tests", false, cmds); + + string reason = queue.GetBlockedReason(job.Ticket); + Assert.That(reason, Is.Null); +} +``` + +Add required using at top of `BatchExecuteAsyncTests.cs`: +```csharp +using System.Collections.Generic; +``` + +**Step 2: Run tests to verify they fail** + +Expected: compile error — `GetBlockedReason` doesn't exist. + +**Step 3: Implement** + +Add to `MCPForUnity/Editor/Tools/CommandQueue.cs` after the `GetStatus` method (after line 106): + +```csharp +/// +/// Returns the reason a queued job is blocked, or null if not blocked. +/// +public string GetBlockedReason(string ticket) +{ + var job = _store.GetJob(ticket); + if (job == null || job.Status != JobStatus.Queued) return null; + if (!job.CausesDomainReload) return null; + if (!IsEditorBusy()) return null; + + if (MCPForUnity.Editor.Services.TestJobManager.HasRunningJob) + return "tests_running"; + if (UnityEditor.EditorApplication.isCompiling) + return "compiling"; + return "editor_busy"; +} +``` + +Add `using MCPForUnity.Editor.Services;` at the top of `CommandQueue.cs`. + +In `MCPForUnity/Editor/Tools/PollJob.cs`, in the `JobStatus.Queued` case (line 27-47), add `blocked_by` to the data object. Replace the existing `Queued` case with: + +```csharp +case JobStatus.Queued: + var ahead = CommandGatewayState.Queue.GetAheadOf(ticket); + string blockedBy = CommandGatewayState.Queue.GetBlockedReason(ticket); + return new PendingResponse( + blockedBy != null + ? $"Queued at position {ahead.Count}. Blocked: {blockedBy}." + : $"Queued at position {ahead.Count}.", + pollIntervalSeconds: 2.0, + data: new + { + ticket = job.Ticket, + status = "queued", + position = ahead.Count, + blocked_by = blockedBy, + agent = job.Agent, + label = job.Label, + ahead = ahead.ConvertAll(j => (object)new + { + ticket = j.Ticket, + agent = j.Agent, + label = j.Label, + tier = j.Tier.ToString().ToLowerInvariant(), + status = j.Status.ToString().ToLowerInvariant() + }) + }); +``` + +**Step 4: Run tests to verify they pass** + +Expected: all tests PASS. + +**Step 5: Commit** + +```bash +cd /home/liory/Github/unity-mcp-fork +git add MCPForUnity/Editor/Tools/PollJob.cs MCPForUnity/Editor/Tools/CommandQueue.cs TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/BatchExecuteAsyncTests.cs +git commit -m "feat(tools): add blocked_by reason to poll_job response + +When a queued job is held because tests are running or compilation +is active, poll_job includes blocked_by: 'tests_running' or 'compiling'." +``` + +--- + +### Task 6: Add SessionState persistence to gateway queue + +**Files:** +- Modify: `MCPForUnity/Editor/Tools/TicketStore.cs` +- Modify: `MCPForUnity/Editor/Tools/CommandGatewayState.cs` +- Modify: `TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/TicketStoreTests.cs` + +**Step 1: Write the failing tests** + +Add to `TicketStoreTests.cs`: + +```csharp +[Test] +public void ToJson_FromJson_RoundTrip_PreservesJobs() +{ + var job = _store.CreateJob("agent-1", "test-label", false, ExecutionTier.Heavy); + job.CausesDomainReload = true; + job.Commands = new System.Collections.Generic.List + { + new() { Tool = "refresh_unity", Params = new JObject { ["compile"] = "request" }, Tier = ExecutionTier.Heavy, CausesDomainReload = true } + }; + + string json = _store.ToJson(); + var restored = new TicketStore(); + restored.FromJson(json); + + var restoredJob = restored.GetJob(job.Ticket); + Assert.That(restoredJob, Is.Not.Null); + Assert.That(restoredJob.Agent, Is.EqualTo("agent-1")); + Assert.That(restoredJob.Label, Is.EqualTo("test-label")); + Assert.That(restoredJob.CausesDomainReload, Is.True); + Assert.That(restoredJob.Tier, Is.EqualTo(ExecutionTier.Heavy)); +} + +[Test] +public void ToJson_FromJson_PreservesNextId() +{ + _store.CreateJob("a", "j1", false, ExecutionTier.Smooth); + _store.CreateJob("b", "j2", false, ExecutionTier.Heavy); + + string json = _store.ToJson(); + var restored = new TicketStore(); + restored.FromJson(json); + + // Next ticket should not collide + var j3 = restored.CreateJob("c", "j3", false, ExecutionTier.Instant); + Assert.That(j3.Ticket, Is.EqualTo("t-000002")); +} + +[Test] +public void FromJson_RunningJobs_MarkedFailed() +{ + var job = _store.CreateJob("agent-1", "test", false, ExecutionTier.Heavy); + job.Status = JobStatus.Running; + + string json = _store.ToJson(); + var restored = new TicketStore(); + restored.FromJson(json); + + var restoredJob = restored.GetJob(job.Ticket); + Assert.That(restoredJob.Status, Is.EqualTo(JobStatus.Failed)); + Assert.That(restoredJob.Error, Does.Contain("domain reload")); +} + +[Test] +public void FromJson_QueuedJobs_StayQueued() +{ + var job = _store.CreateJob("agent-1", "test", false, ExecutionTier.Heavy); + // Status is Queued by default + + string json = _store.ToJson(); + var restored = new TicketStore(); + restored.FromJson(json); + + var restoredJob = restored.GetJob(job.Ticket); + Assert.That(restoredJob.Status, Is.EqualTo(JobStatus.Queued)); +} + +[Test] +public void FromJson_EmptyJson_NoError() +{ + var restored = new TicketStore(); + restored.FromJson(""); + Assert.That(restored.QueueDepth, Is.EqualTo(0)); +} + +[Test] +public void FromJson_NullJson_NoError() +{ + var restored = new TicketStore(); + restored.FromJson(null); + Assert.That(restored.QueueDepth, Is.EqualTo(0)); +} +``` + +Add required usings at top of `TicketStoreTests.cs`: +```csharp +using Newtonsoft.Json.Linq; +using System.Collections.Generic; +``` + +**Step 2: Run tests to verify they fail** + +Expected: compile error — `ToJson`/`FromJson` don't exist on `TicketStore`. + +**Step 3: Implement serialization on TicketStore** + +Add to `MCPForUnity/Editor/Tools/TicketStore.cs`. Add usings at top: + +```csharp +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +``` + +Add these methods after the existing `QueueDepth` property (after line 79): + +```csharp +/// +/// Serialize all jobs and the next-ID counter to JSON for SessionState persistence. +/// +public string ToJson() +{ + var state = new JObject + { + ["next_id"] = _nextId, + ["jobs"] = new JArray(_jobs.Values.Select(j => new JObject + { + ["ticket"] = j.Ticket, + ["agent"] = j.Agent, + ["label"] = j.Label, + ["atomic"] = j.Atomic, + ["tier"] = (int)j.Tier, + ["status"] = (int)j.Status, + ["causes_domain_reload"] = j.CausesDomainReload, + ["created_at"] = j.CreatedAt.ToString("O"), + ["completed_at"] = j.CompletedAt?.ToString("O"), + ["error"] = j.Error, + ["current_index"] = j.CurrentIndex, + ["commands"] = new JArray((j.Commands ?? new List()).Select(c => new JObject + { + ["tool"] = c.Tool, + ["tier"] = (int)c.Tier, + ["causes_domain_reload"] = c.CausesDomainReload, + ["params"] = c.Params + })) + })) + }; + return state.ToString(Formatting.None); +} + +/// +/// Restore jobs from JSON. Running jobs are marked Failed (interrupted by domain reload). +/// +public void FromJson(string json) +{ + if (string.IsNullOrWhiteSpace(json)) return; + + try + { + var state = JObject.Parse(json); + _nextId = state.Value("next_id"); + _jobs.Clear(); + + var jobs = state["jobs"] as JArray; + if (jobs == null) return; + + foreach (var jt in jobs) + { + if (jt is not JObject jo) continue; + var ticket = jo.Value("ticket"); + if (string.IsNullOrEmpty(ticket)) continue; + + var status = (JobStatus)jo.Value("status"); + string error = jo.Value("error"); + + // Running jobs were interrupted by domain reload + if (status == JobStatus.Running) + { + status = JobStatus.Failed; + error = "Interrupted by domain reload"; + } + + var commands = new List(); + if (jo["commands"] is JArray cmds) + { + foreach (var ct in cmds) + { + if (ct is not JObject co) continue; + commands.Add(new BatchCommand + { + Tool = co.Value("tool"), + Tier = (ExecutionTier)co.Value("tier"), + CausesDomainReload = co.Value("causes_domain_reload"), + Params = co["params"] as JObject ?? new JObject() + }); + } + } + + var completedStr = jo.Value("completed_at"); + + _jobs[ticket] = new BatchJob + { + Ticket = ticket, + Agent = jo.Value("agent") ?? "anonymous", + Label = jo.Value("label") ?? "", + Atomic = jo.Value("atomic"), + Tier = (ExecutionTier)jo.Value("tier"), + Status = status, + CausesDomainReload = jo.Value("causes_domain_reload"), + CreatedAt = DateTime.TryParse(jo.Value("created_at"), out var ca) ? ca : DateTime.UtcNow, + CompletedAt = !string.IsNullOrEmpty(completedStr) && DateTime.TryParse(completedStr, out var comp) ? comp : null, + Error = error, + CurrentIndex = jo.Value("current_index"), + Commands = commands, + Results = new List() + }; + } + } + catch + { + // Best-effort restore; never block editor load + } +} +``` + +**Step 4: Run tests to verify they pass** + +Expected: all 6 new serialization tests PASS. + +**Step 5: Wire SessionState in CommandGatewayState** + +Modify `MCPForUnity/Editor/Tools/CommandGatewayState.cs` to persist and restore. Replace entire file: + +```csharp +using MCPForUnity.Editor.Services; +using UnityEditor; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Singleton state for the command gateway queue. + /// Hooks into EditorApplication.update for tick processing. + /// Persists queue state across domain reloads via SessionState. + /// + [InitializeOnLoad] + public static class CommandGatewayState + { + const string SessionKey = "MCPForUnity.GatewayQueueV1"; + + public static readonly CommandQueue Queue = new(); + + static CommandGatewayState() + { + // Restore queue state from before domain reload + string json = SessionState.GetString(SessionKey, ""); + if (!string.IsNullOrEmpty(json)) + Queue.RestoreFromJson(json); + + Queue.IsEditorBusy = () => + TestJobManager.HasRunningJob + || EditorApplication.isCompiling; + + AssemblyReloadEvents.beforeAssemblyReload += OnBeforeReload; + EditorApplication.update += OnUpdate; + } + + static void OnBeforeReload() + { + SessionState.SetString(SessionKey, Queue.PersistToJson()); + } + + static void OnUpdate() + { + Queue.ProcessTick(async (tool, @params) => + await CommandRegistry.InvokeCommandAsync(tool, @params)); + } + } +} +``` + +Add these delegation methods to `MCPForUnity/Editor/Tools/CommandQueue.cs` (after `GetBlockedReason`): + +```csharp +/// +/// Serialize queue state for SessionState persistence. +/// +public string PersistToJson() => _store.ToJson(); + +/// +/// Restore queue state after domain reload. Re-enqueues any queued heavy jobs. +/// +public void RestoreFromJson(string json) +{ + _store.FromJson(json); + + // Re-populate the heavy queue from restored queued jobs + foreach (var job in _store.GetQueuedJobs()) + { + if (job.Tier == ExecutionTier.Heavy) + _heavyQueue.Enqueue(job.Ticket); + } +} +``` + +**Step 6: Run all gateway tests** + +Expected: all existing + new tests PASS. + +**Step 7: Commit** + +```bash +cd /home/liory/Github/unity-mcp-fork +git add MCPForUnity/Editor/Tools/TicketStore.cs MCPForUnity/Editor/Tools/CommandQueue.cs MCPForUnity/Editor/Tools/CommandGatewayState.cs TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/TicketStoreTests.cs +git commit -m "feat(tools): persist gateway queue state across domain reloads + +TicketStore serializes to JSON via SessionState. Running jobs are +marked Failed on restore (interrupted by domain reload). Queued jobs +survive and re-enter the heavy queue. Follows TestJobManager pattern." +``` + +--- + +### Task 7: MCP integration verification + +**Files:** None (manual testing only) + +**Step 1: Verify compile clean** + +``` +refresh_unity(scope=all, compile=request, wait_for_ready=true) +read_console(types=["error"]) → 0 errors +``` + +**Step 2: Run all gateway tests** + +``` +run_tests(mode=EditMode, assembly_names=["MCPForUnity.Tests.Editor"]) +get_test_job(job_id=..., wait_timeout=60, include_failed_tests=true) +``` + +Expected: 0 failures. + +**Step 3: Integration test — concurrent operations** + +Test the original scenario: + +``` +# 1. Submit test run via async gateway +execute_custom_tool("batch_execute", { + commands: [{tool: "run_tests", params: {mode: "EditMode", assembly_names: ["FineTuner.Tests"]}}], + async: true, agent: "agent-1", label: "Test Suite Run" +}) +→ returns ticket t-NNNNNN + +# 2. Immediately submit refresh +execute_custom_tool("batch_execute", { + commands: [{tool: "refresh_unity", params: {scope: "all", compile: "request"}}], + async: true, agent: "agent-2", label: "Unity Refresh" +}) +→ returns ticket t-NNNNNN+1 + +# 3. Poll the refresh ticket +execute_custom_tool("batch_execute", { + commands: [{tool: "poll_job", params: {ticket: "t-NNNNNN+1"}}] +}) +→ status: "queued", blocked_by: "tests_running" + +# 4. Poll the test ticket +execute_custom_tool("batch_execute", { + commands: [{tool: "get_test_job", params: {job_id: "..."}}] +}) +→ status: "running" or "succeeded" + +# 5. After tests complete, poll refresh again +→ status should change from "queued" to "running" or "done" +``` + +**Step 4: Commit any integration test fixes** + +If any issues found, fix and commit with `fix(tools):` prefix. From fd83cfac53d615483ecefa1c3164d99f14c97b04 Mon Sep 17 00:00:00 2001 From: lior Date: Wed, 25 Feb 2026 11:48:52 +0200 Subject: [PATCH 12/33] feat(tools): add CausesDomainReload to CommandClassifier Only refresh_unity (compile!=none) and manage_editor (play) trigger domain reload. Script/shader file ops are just disk I/O. Co-Authored-By: Claude Opus 4.6 --- MCPForUnity/Editor/Tools/CommandClassifier.cs | 15 +++++ .../EditMode/Tools/CommandClassifierTests.cs | 62 +++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/MCPForUnity/Editor/Tools/CommandClassifier.cs b/MCPForUnity/Editor/Tools/CommandClassifier.cs index c110a3406..87cf7d981 100644 --- a/MCPForUnity/Editor/Tools/CommandClassifier.cs +++ b/MCPForUnity/Editor/Tools/CommandClassifier.cs @@ -42,6 +42,21 @@ public static ExecutionTier ClassifyBatch( return max; } + /// + /// Returns true if the given command would trigger a domain reload (compilation or play mode entry). + /// + public static bool CausesDomainReload(string toolName, JObject @params) + { + if (@params == null) return false; + + return toolName switch + { + "refresh_unity" => @params.Value("compile") != "none", + "manage_editor" => @params.Value("action") == "play", + _ => false + }; + } + static ExecutionTier ClassifyManageScene(string action, ExecutionTier fallback) { return action switch diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandClassifierTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandClassifierTests.cs index 0957fda4f..447bfe86e 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandClassifierTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandClassifierTests.cs @@ -121,5 +121,67 @@ public void ClassifyBatch_MixedSmoothAndInstant_ReturnsSmooth() var tier = CommandClassifier.ClassifyBatch(commands); Assert.That(tier, Is.EqualTo(ExecutionTier.Smooth)); } + + [Test] + public void CausesDomainReload_RefreshUnity_CompileRequest_ReturnsTrue() + { + var p = new JObject { ["compile"] = "request" }; + Assert.That(CommandClassifier.CausesDomainReload("refresh_unity", p), Is.True); + } + + [Test] + public void CausesDomainReload_RefreshUnity_CompileNone_ReturnsFalse() + { + var p = new JObject { ["compile"] = "none" }; + Assert.That(CommandClassifier.CausesDomainReload("refresh_unity", p), Is.False); + } + + [Test] + public void CausesDomainReload_RefreshUnity_NoCompileParam_ReturnsTrue() + { + var p = new JObject { ["scope"] = "all" }; + Assert.That(CommandClassifier.CausesDomainReload("refresh_unity", p), Is.True); + } + + [Test] + public void CausesDomainReload_ManageEditor_Play_ReturnsTrue() + { + var p = new JObject { ["action"] = "play" }; + Assert.That(CommandClassifier.CausesDomainReload("manage_editor", p), Is.True); + } + + [Test] + public void CausesDomainReload_ManageEditor_Stop_ReturnsFalse() + { + var p = new JObject { ["action"] = "stop" }; + Assert.That(CommandClassifier.CausesDomainReload("manage_editor", p), Is.False); + } + + [Test] + public void CausesDomainReload_RunTests_ReturnsFalse() + { + var p = new JObject { ["mode"] = "EditMode" }; + Assert.That(CommandClassifier.CausesDomainReload("run_tests", p), Is.False); + } + + [Test] + public void CausesDomainReload_ManageScript_Create_ReturnsFalse() + { + var p = new JObject { ["action"] = "create" }; + Assert.That(CommandClassifier.CausesDomainReload("manage_script", p), Is.False); + } + + [Test] + public void CausesDomainReload_ManageScene_Load_ReturnsFalse() + { + var p = new JObject { ["action"] = "load" }; + Assert.That(CommandClassifier.CausesDomainReload("manage_scene", p), Is.False); + } + + [Test] + public void CausesDomainReload_NullParams_ReturnsFalse() + { + Assert.That(CommandClassifier.CausesDomainReload("refresh_unity", null), Is.False); + } } } From 8d66bbcb19af26a58a7b476eb375c5667115425c Mon Sep 17 00:00:00 2001 From: lior Date: Wed, 25 Feb 2026 11:50:42 +0200 Subject: [PATCH 13/33] feat(tools): add CausesDomainReload property to BatchCommand and BatchJob Co-Authored-By: Claude Opus 4.6 --- MCPForUnity/Editor/Tools/BatchJob.cs | 2 + .../Tests/EditMode/Tools/CommandQueueTests.cs | 37 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/MCPForUnity/Editor/Tools/BatchJob.cs b/MCPForUnity/Editor/Tools/BatchJob.cs index a8b7da932..a29807b4e 100644 --- a/MCPForUnity/Editor/Tools/BatchJob.cs +++ b/MCPForUnity/Editor/Tools/BatchJob.cs @@ -16,6 +16,7 @@ public class BatchJob public string Label { get; set; } public bool Atomic { get; set; } public ExecutionTier Tier { get; set; } + public bool CausesDomainReload { get; set; } public JobStatus Status { get; set; } = JobStatus.Queued; public List Commands { get; set; } = new(); @@ -34,6 +35,7 @@ public class BatchCommand public string Tool { get; set; } public JObject Params { get; set; } public ExecutionTier Tier { get; set; } + public bool CausesDomainReload { get; set; } } public class AgentStats diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandQueueTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandQueueTests.cs index 3c1751367..56dc7784e 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandQueueTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandQueueTests.cs @@ -135,5 +135,42 @@ public void Submit_MultipleHeavyJobs_AllQueued() // All three should be queued Assert.That(_queue.QueueDepth, Is.GreaterThanOrEqualTo(3)); } + + [Test] + public void BatchCommand_CausesDomainReload_DefaultsFalse() + { + var cmd = new BatchCommand { Tool = "find_gameobjects", Params = new JObject(), Tier = ExecutionTier.Instant }; + Assert.That(cmd.CausesDomainReload, Is.False); + } + + [Test] + public void BatchJob_CausesDomainReload_DefaultsFalse() + { + var job = new BatchJob(); + Assert.That(job.CausesDomainReload, Is.False); + } + + [Test] + public void Submit_WithReloadCommand_SetsJobCausesDomainReload() + { + var cmds = new List + { + new() { Tool = "find_gameobjects", Params = new JObject(), Tier = ExecutionTier.Instant, CausesDomainReload = false }, + new() { Tool = "refresh_unity", Params = new JObject(), Tier = ExecutionTier.Heavy, CausesDomainReload = true } + }; + var job = _queue.Submit("agent-1", "test", false, cmds); + Assert.That(job.CausesDomainReload, Is.True); + } + + [Test] + public void Submit_WithoutReloadCommand_JobCausesDomainReloadFalse() + { + var cmds = new List + { + new() { Tool = "run_tests", Params = new JObject(), Tier = ExecutionTier.Heavy, CausesDomainReload = false } + }; + var job = _queue.Submit("agent-1", "test", false, cmds); + Assert.That(job.CausesDomainReload, Is.False); + } } } From 6079cc5c8c795062bb0aaf07d8ff70ccbf14a788 Mon Sep 17 00:00:00 2001 From: lior Date: Wed, 25 Feb 2026 11:50:56 +0200 Subject: [PATCH 14/33] feat(tools): propagate CausesDomainReload through queue submission Co-Authored-By: Claude Opus 4.6 --- MCPForUnity/Editor/Tools/BatchExecute.cs | 2 +- MCPForUnity/Editor/Tools/CommandQueue.cs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/MCPForUnity/Editor/Tools/BatchExecute.cs b/MCPForUnity/Editor/Tools/BatchExecute.cs index f51a9cec3..eed6759ba 100644 --- a/MCPForUnity/Editor/Tools/BatchExecute.cs +++ b/MCPForUnity/Editor/Tools/BatchExecute.cs @@ -264,7 +264,7 @@ private static object HandleAsyncSubmit(JObject @params, JArray commandsToken) var toolTier = CommandRegistry.GetToolTier(toolName); var effectiveTier = CommandClassifier.Classify(toolName, toolTier, cmdParams); - commands.Add(new BatchCommand { Tool = toolName, Params = cmdParams, Tier = effectiveTier }); + commands.Add(new BatchCommand { Tool = toolName, Params = cmdParams, Tier = effectiveTier, CausesDomainReload = CommandClassifier.CausesDomainReload(toolName, cmdParams) }); } if (commands.Count == 0) diff --git a/MCPForUnity/Editor/Tools/CommandQueue.cs b/MCPForUnity/Editor/Tools/CommandQueue.cs index 43710f5ee..e4d839044 100644 --- a/MCPForUnity/Editor/Tools/CommandQueue.cs +++ b/MCPForUnity/Editor/Tools/CommandQueue.cs @@ -35,6 +35,7 @@ public BatchJob Submit(string agent, string label, bool atomic, List c.CausesDomainReload); if (batchTier == ExecutionTier.Heavy) _heavyQueue.Enqueue(job.Ticket); From 4215cd250eccdb5dabd88ace53efc3ca255896df Mon Sep 17 00:00:00 2001 From: lior Date: Wed, 25 Feb 2026 11:53:04 +0200 Subject: [PATCH 15/33] feat(tools): add domain-reload guard to ProcessTick Domain-reload-causing jobs (refresh_unity compile, manage_editor play) are held in queue while TestJobManager.HasRunningJob or EditorApplication.isCompiling is true. Non-reload heavy jobs proceed normally. Co-Authored-By: Claude Opus 4.6 --- .../Editor/Tools/CommandGatewayState.cs | 5 ++ MCPForUnity/Editor/Tools/CommandQueue.cs | 19 +++++- .../Tests/EditMode/Tools/CommandQueueTests.cs | 65 +++++++++++++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) diff --git a/MCPForUnity/Editor/Tools/CommandGatewayState.cs b/MCPForUnity/Editor/Tools/CommandGatewayState.cs index 7e219ca14..e2c57c8b1 100644 --- a/MCPForUnity/Editor/Tools/CommandGatewayState.cs +++ b/MCPForUnity/Editor/Tools/CommandGatewayState.cs @@ -1,3 +1,4 @@ +using MCPForUnity.Editor.Services; using UnityEditor; namespace MCPForUnity.Editor.Tools @@ -13,6 +14,10 @@ public static class CommandGatewayState static CommandGatewayState() { + Queue.IsEditorBusy = () => + TestJobManager.HasRunningJob + || EditorApplication.isCompiling; + EditorApplication.update += OnUpdate; } diff --git a/MCPForUnity/Editor/Tools/CommandQueue.cs b/MCPForUnity/Editor/Tools/CommandQueue.cs index e4d839044..a4d560266 100644 --- a/MCPForUnity/Editor/Tools/CommandQueue.cs +++ b/MCPForUnity/Editor/Tools/CommandQueue.cs @@ -21,6 +21,12 @@ public class CommandQueue static readonly TimeSpan TicketExpiry = TimeSpan.FromMinutes(5); + /// + /// Predicate that returns true when domain-reload operations should be deferred. + /// Default always returns false. CommandGatewayState wires this to the real checks. + /// + public Func IsEditorBusy { get; set; } = () => false; + public bool HasActiveHeavy => _activeHeavyTicket != null; public int QueueDepth => _store.QueueDepth; public int SmoothInFlight => _smoothInFlight.Count; @@ -133,11 +139,22 @@ public void ProcessTick(Func> executeCommand) // 2. If heavy queue has items and no smooth in flight, start next heavy if (_heavyQueue.Count > 0 && _smoothInFlight.Count == 0) { - while (_heavyQueue.Count > 0) + bool editorBusy = IsEditorBusy(); + // Peek-and-skip: find the first eligible heavy job + int count = _heavyQueue.Count; + for (int i = 0; i < count; i++) { var ticket = _heavyQueue.Dequeue(); var job = _store.GetJob(ticket); if (job == null || job.Status == JobStatus.Cancelled) continue; + + // Guard: domain-reload jobs must wait when editor is busy + if (job.CausesDomainReload && editorBusy) + { + _heavyQueue.Enqueue(ticket); // put back at end + continue; + } + _activeHeavyTicket = ticket; _ = ExecuteJob(job, executeCommand); return; diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandQueueTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandQueueTests.cs index 56dc7784e..a3d9829ac 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandQueueTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandQueueTests.cs @@ -172,5 +172,70 @@ public void Submit_WithoutReloadCommand_JobCausesDomainReloadFalse() var job = _queue.Submit("agent-1", "test", false, cmds); Assert.That(job.CausesDomainReload, Is.False); } + + [Test] + public void ProcessTick_ReloadJob_SkippedWhenEditorBusy() + { + _queue.IsEditorBusy = () => true; + var cmds = new List + { + new() { Tool = "refresh_unity", Params = new JObject(), Tier = ExecutionTier.Heavy, CausesDomainReload = true } + }; + var job = _queue.Submit("agent-1", "refresh", false, cmds); + + // Tick should NOT start the job because editor is busy + _queue.ProcessTick(DummyExecutor); + Assert.That(job.Status, Is.EqualTo(JobStatus.Queued)); + Assert.That(_queue.HasActiveHeavy, Is.False); + } + + [Test] + public void ProcessTick_ReloadJob_ProceedsWhenEditorNotBusy() + { + _queue.IsEditorBusy = () => false; + var cmds = new List + { + new() { Tool = "refresh_unity", Params = new JObject(), Tier = ExecutionTier.Heavy, CausesDomainReload = true } + }; + var job = _queue.Submit("agent-1", "refresh", false, cmds); + + _queue.ProcessTick(DummyExecutor); + Assert.That(job.Status, Is.Not.EqualTo(JobStatus.Queued)); + } + + [Test] + public void ProcessTick_NonReloadHeavyJob_ProceedsEvenWhenBusy() + { + _queue.IsEditorBusy = () => true; + var cmds = new List + { + new() { Tool = "run_tests", Params = new JObject(), Tier = ExecutionTier.Heavy, CausesDomainReload = false } + }; + var job = _queue.Submit("agent-1", "tests", false, cmds); + + _queue.ProcessTick(DummyExecutor); + Assert.That(job.Status, Is.Not.EqualTo(JobStatus.Queued)); + } + + [Test] + public void ProcessTick_ReloadJobBehindNonReload_NonReloadProceeds() + { + _queue.IsEditorBusy = () => true; + var testCmds = new List + { + new() { Tool = "run_tests", Params = new JObject(), Tier = ExecutionTier.Heavy, CausesDomainReload = false } + }; + var refreshCmds = new List + { + new() { Tool = "refresh_unity", Params = new JObject(), Tier = ExecutionTier.Heavy, CausesDomainReload = true } + }; + var testJob = _queue.Submit("agent-1", "tests", false, testCmds); + var refreshJob = _queue.Submit("agent-2", "refresh", false, refreshCmds); + + _queue.ProcessTick(DummyExecutor); + // test job should start (non-reload), refresh should stay queued + Assert.That(testJob.Status, Is.Not.EqualTo(JobStatus.Queued)); + Assert.That(refreshJob.Status, Is.EqualTo(JobStatus.Queued)); + } } } From d08a01779008ce0b3edd0c0b8ec7c2c317ff2b9b Mon Sep 17 00:00:00 2001 From: lior Date: Wed, 25 Feb 2026 11:55:42 +0200 Subject: [PATCH 16/33] feat(tools): add blocked_by reason to poll_job response When a queued domain-reload job is blocked by editor activity (tests running, compiling), the poll_job response now includes a blocked_by field indicating the reason. Non-reload jobs return null. Co-Authored-By: Claude Opus 4.6 --- MCPForUnity/Editor/Tools/CommandQueue.cs | 17 ++++++++++ MCPForUnity/Editor/Tools/PollJob.cs | 2 ++ .../EditMode/Tools/BatchExecuteAsyncTests.cs | 31 +++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/MCPForUnity/Editor/Tools/CommandQueue.cs b/MCPForUnity/Editor/Tools/CommandQueue.cs index a4d560266..5411b1b8d 100644 --- a/MCPForUnity/Editor/Tools/CommandQueue.cs +++ b/MCPForUnity/Editor/Tools/CommandQueue.cs @@ -91,6 +91,23 @@ public List GetAheadOf(string ticket) return result; } + /// + /// Returns the reason a queued job is blocked, or null if not blocked. + /// + public string GetBlockedReason(string ticket) + { + var job = _store.GetJob(ticket); + if (job == null || job.Status != JobStatus.Queued) return null; + if (!job.CausesDomainReload) return null; + if (!IsEditorBusy()) return null; + + if (MCPForUnity.Editor.Services.TestJobManager.HasRunningJob) + return "tests_running"; + if (UnityEditor.EditorApplication.isCompiling) + return "compiling"; + return "editor_busy"; + } + /// /// Get overall queue status. /// diff --git a/MCPForUnity/Editor/Tools/PollJob.cs b/MCPForUnity/Editor/Tools/PollJob.cs index 795080b28..4db07b869 100644 --- a/MCPForUnity/Editor/Tools/PollJob.cs +++ b/MCPForUnity/Editor/Tools/PollJob.cs @@ -26,6 +26,7 @@ public static object HandleCommand(JObject @params) { case JobStatus.Queued: var ahead = CommandGatewayState.Queue.GetAheadOf(ticket); + string blockedBy = CommandGatewayState.Queue.GetBlockedReason(ticket); return new PendingResponse( $"Queued at position {ahead.Count}.", pollIntervalSeconds: 2.0, @@ -36,6 +37,7 @@ public static object HandleCommand(JObject @params) position = ahead.Count, agent = job.Agent, label = job.Label, + blocked_by = blockedBy, ahead = ahead.ConvertAll(j => (object)new { ticket = j.Ticket, diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/BatchExecuteAsyncTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/BatchExecuteAsyncTests.cs index 31deb69ea..60ca6fe11 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/BatchExecuteAsyncTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/BatchExecuteAsyncTests.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using NUnit.Framework; using Newtonsoft.Json.Linq; using MCPForUnity.Editor.Tools; @@ -57,5 +58,35 @@ public void BatchExecuteMaxCommands_DefaultIs25() Assert.That(BatchExecute.DefaultMaxCommandsPerBatch, Is.EqualTo(25)); Assert.That(BatchExecute.AbsoluteMaxCommandsPerBatch, Is.EqualTo(100)); } + + [Test] + public void PollJob_QueuedReloadJob_IncludesBlockedBy_WhenBusy() + { + var queue = new CommandQueue(); + queue.IsEditorBusy = () => true; + var cmds = new List + { + new() { Tool = "refresh_unity", Params = new JObject(), Tier = ExecutionTier.Heavy, CausesDomainReload = true } + }; + var job = queue.Submit("agent-1", "refresh", false, cmds); + + string reason = queue.GetBlockedReason(job.Ticket); + Assert.That(reason, Is.Not.Null); + } + + [Test] + public void PollJob_QueuedNonReloadJob_BlockedByIsNull() + { + var queue = new CommandQueue(); + queue.IsEditorBusy = () => true; + var cmds = new List + { + new() { Tool = "run_tests", Params = new JObject(), Tier = ExecutionTier.Heavy, CausesDomainReload = false } + }; + var job = queue.Submit("agent-1", "tests", false, cmds); + + string reason = queue.GetBlockedReason(job.Ticket); + Assert.That(reason, Is.Null); + } } } From a4842ff6b76b9f76ff4db4dd68eabcd3a976dd63 Mon Sep 17 00:00:00 2001 From: lior Date: Wed, 25 Feb 2026 11:57:04 +0200 Subject: [PATCH 17/33] feat(tools): persist gateway queue state across domain reloads TicketStore now supports ToJson/FromJson serialization. Running jobs are marked failed on restore (interrupted by reload), queued jobs survive and re-enter the heavy queue. CommandGatewayState hooks AssemblyReloadEvents to persist via SessionState. Co-Authored-By: Claude Opus 4.6 --- .../Editor/Tools/CommandGatewayState.cs | 12 ++ MCPForUnity/Editor/Tools/CommandQueue.cs | 20 ++++ MCPForUnity/Editor/Tools/TicketStore.cs | 109 ++++++++++++++++++ .../Tests/EditMode/Tools/TicketStoreTests.cs | 81 +++++++++++++ 4 files changed, 222 insertions(+) diff --git a/MCPForUnity/Editor/Tools/CommandGatewayState.cs b/MCPForUnity/Editor/Tools/CommandGatewayState.cs index e2c57c8b1..9812fed04 100644 --- a/MCPForUnity/Editor/Tools/CommandGatewayState.cs +++ b/MCPForUnity/Editor/Tools/CommandGatewayState.cs @@ -6,18 +6,30 @@ namespace MCPForUnity.Editor.Tools /// /// Singleton state for the command gateway queue. /// Hooks into EditorApplication.update for tick processing. + /// Persists queue state across domain reloads via SessionState. /// [InitializeOnLoad] public static class CommandGatewayState { + const string SessionKey = "MCPForUnity.GatewayQueueV1"; + public static readonly CommandQueue Queue = new(); static CommandGatewayState() { + // Restore queue state from previous domain reload + string json = SessionState.GetString(SessionKey, ""); + if (!string.IsNullOrEmpty(json)) + Queue.RestoreFromJson(json); + Queue.IsEditorBusy = () => TestJobManager.HasRunningJob || EditorApplication.isCompiling; + // Persist before next domain reload + AssemblyReloadEvents.beforeAssemblyReload += () => + SessionState.SetString(SessionKey, Queue.PersistToJson()); + EditorApplication.update += OnUpdate; } diff --git a/MCPForUnity/Editor/Tools/CommandQueue.cs b/MCPForUnity/Editor/Tools/CommandQueue.cs index 5411b1b8d..4f2208a5c 100644 --- a/MCPForUnity/Editor/Tools/CommandQueue.cs +++ b/MCPForUnity/Editor/Tools/CommandQueue.cs @@ -129,6 +129,26 @@ public object GetStatus() }; } + /// + /// Serialize queue state for SessionState persistence. + /// + public string PersistToJson() => _store.ToJson(); + + /// + /// Restore queue state after domain reload. Re-enqueues any queued heavy jobs. + /// + public void RestoreFromJson(string json) + { + _store.FromJson(json); + + // Re-populate the heavy queue from restored queued jobs + foreach (var job in _store.GetQueuedJobs()) + { + if (job.Tier == ExecutionTier.Heavy) + _heavyQueue.Enqueue(job.Ticket); + } + } + /// /// Called every EditorApplication.update frame. Processes the queue. /// diff --git a/MCPForUnity/Editor/Tools/TicketStore.cs b/MCPForUnity/Editor/Tools/TicketStore.cs index 4922c495a..04e1e9506 100644 --- a/MCPForUnity/Editor/Tools/TicketStore.cs +++ b/MCPForUnity/Editor/Tools/TicketStore.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; namespace MCPForUnity.Editor.Tools { @@ -77,5 +79,112 @@ public Dictionary GetAgentStats() } public int QueueDepth => _jobs.Values.Count(j => j.Status == JobStatus.Queued); + + /// + /// Serialize all jobs to JSON for SessionState persistence. + /// + public string ToJson() + { + var state = new JObject + { + ["next_id"] = _nextId, + ["jobs"] = new JArray(_jobs.Values.Select(j => new JObject + { + ["ticket"] = j.Ticket, + ["agent"] = j.Agent, + ["label"] = j.Label, + ["atomic"] = j.Atomic, + ["tier"] = (int)j.Tier, + ["status"] = (int)j.Status, + ["causes_domain_reload"] = j.CausesDomainReload, + ["created_at"] = j.CreatedAt.ToString("O"), + ["completed_at"] = j.CompletedAt?.ToString("O"), + ["error"] = j.Error, + ["current_index"] = j.CurrentIndex, + ["commands"] = new JArray((j.Commands ?? new List()).Select(c => new JObject + { + ["tool"] = c.Tool, + ["tier"] = (int)c.Tier, + ["causes_domain_reload"] = c.CausesDomainReload, + ["params"] = c.Params + })) + })) + }; + return state.ToString(Formatting.None); + } + + /// + /// Restore jobs from JSON. Running jobs are marked failed (interrupted by domain reload). + /// + public void FromJson(string json) + { + if (string.IsNullOrWhiteSpace(json)) return; + + try + { + var state = JObject.Parse(json); + _nextId = state.Value("next_id"); + _jobs.Clear(); + + var jobs = state["jobs"] as JArray; + if (jobs == null) return; + + foreach (var jt in jobs) + { + if (jt is not JObject jo) continue; + var ticket = jo.Value("ticket"); + if (string.IsNullOrEmpty(ticket)) continue; + + var status = (JobStatus)jo.Value("status"); + string error = jo.Value("error"); + + // Jobs that were running when domain reload hit are now dead + if (status == JobStatus.Running) + { + status = JobStatus.Failed; + error = "Interrupted by domain reload"; + } + + var commands = new List(); + if (jo["commands"] is JArray cmds) + { + foreach (var ct in cmds) + { + if (ct is not JObject co) continue; + commands.Add(new BatchCommand + { + Tool = co.Value("tool"), + Tier = (ExecutionTier)co.Value("tier"), + CausesDomainReload = co.Value("causes_domain_reload"), + Params = co["params"] as JObject ?? new JObject() + }); + } + } + + var completedStr = jo.Value("completed_at"); + + _jobs[ticket] = new BatchJob + { + Ticket = ticket, + Agent = jo.Value("agent") ?? "anonymous", + Label = jo.Value("label") ?? "", + Atomic = jo.Value("atomic"), + Tier = (ExecutionTier)jo.Value("tier"), + Status = status, + CausesDomainReload = jo.Value("causes_domain_reload"), + CreatedAt = DateTime.TryParse(jo.Value("created_at"), out var ca) ? ca : DateTime.UtcNow, + CompletedAt = !string.IsNullOrEmpty(completedStr) && DateTime.TryParse(completedStr, out var comp) ? comp : null, + Error = error, + CurrentIndex = jo.Value("current_index"), + Commands = commands, + Results = new List() + }; + } + } + catch + { + // Best-effort restore; never block editor load + } + } } } diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/TicketStoreTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/TicketStoreTests.cs index d1b7261e4..9e71fe8dc 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/TicketStoreTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/TicketStoreTests.cs @@ -1,4 +1,5 @@ using NUnit.Framework; +using Newtonsoft.Json.Linq; using MCPForUnity.Editor.Tools; namespace MCPForUnity.Tests.Editor @@ -118,5 +119,85 @@ public void GetQueuedJobs_OrderedByCreationTime() Assert.That(queued[0].Ticket, Is.EqualTo(j1.Ticket)); Assert.That(queued[1].Ticket, Is.EqualTo(j2.Ticket)); } + + [Test] + public void ToJson_FromJson_RoundTrip_PreservesJobs() + { + var job = _store.CreateJob("agent-1", "test-label", false, ExecutionTier.Heavy); + job.CausesDomainReload = true; + job.Commands = new System.Collections.Generic.List + { + new() { Tool = "refresh_unity", Params = new JObject { ["compile"] = "request" }, Tier = ExecutionTier.Heavy, CausesDomainReload = true } + }; + + string json = _store.ToJson(); + var restored = new TicketStore(); + restored.FromJson(json); + + var restoredJob = restored.GetJob(job.Ticket); + Assert.That(restoredJob, Is.Not.Null); + Assert.That(restoredJob.Agent, Is.EqualTo("agent-1")); + Assert.That(restoredJob.Label, Is.EqualTo("test-label")); + Assert.That(restoredJob.CausesDomainReload, Is.True); + Assert.That(restoredJob.Tier, Is.EqualTo(ExecutionTier.Heavy)); + } + + [Test] + public void ToJson_FromJson_PreservesNextId() + { + _store.CreateJob("a", "j1", false, ExecutionTier.Smooth); + _store.CreateJob("b", "j2", false, ExecutionTier.Heavy); + + string json = _store.ToJson(); + var restored = new TicketStore(); + restored.FromJson(json); + + var j3 = restored.CreateJob("c", "j3", false, ExecutionTier.Instant); + Assert.That(j3.Ticket, Is.EqualTo("t-000002")); + } + + [Test] + public void FromJson_RunningJobs_MarkedFailed() + { + var job = _store.CreateJob("agent-1", "test", false, ExecutionTier.Heavy); + job.Status = JobStatus.Running; + + string json = _store.ToJson(); + var restored = new TicketStore(); + restored.FromJson(json); + + var restoredJob = restored.GetJob(job.Ticket); + Assert.That(restoredJob.Status, Is.EqualTo(JobStatus.Failed)); + Assert.That(restoredJob.Error, Does.Contain("domain reload")); + } + + [Test] + public void FromJson_QueuedJobs_StayQueued() + { + var job = _store.CreateJob("agent-1", "test", false, ExecutionTier.Heavy); + + string json = _store.ToJson(); + var restored = new TicketStore(); + restored.FromJson(json); + + var restoredJob = restored.GetJob(job.Ticket); + Assert.That(restoredJob.Status, Is.EqualTo(JobStatus.Queued)); + } + + [Test] + public void FromJson_EmptyJson_NoError() + { + var restored = new TicketStore(); + restored.FromJson(""); + Assert.That(restored.QueueDepth, Is.EqualTo(0)); + } + + [Test] + public void FromJson_NullJson_NoError() + { + var restored = new TicketStore(); + restored.FromJson(null); + Assert.That(restored.QueueDepth, Is.EqualTo(0)); + } } } From 8d9fcae7c59bb1f8e45f5e4d4724594a49d97866 Mon Sep 17 00:00:00 2001 From: lior Date: Wed, 25 Feb 2026 12:24:04 +0200 Subject: [PATCH 18/33] fix(gateway): hold heavy slot while editor busy from async side-effects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When run_tests completes instantly (fire-and-forget) but the TestRunner is still executing, the heavy slot was released immediately, allowing domain-reload jobs like refresh_unity to be dequeued before the guard could catch them. Three fixes: 1. Hold the heavy slot after batch completion while IsEditorBusy() is true — prevents next heavy dequeue until async operations settle 2. One-frame cooldown between heavy job completions — gives async state one editor frame to propagate before the guard check 3. Add TestRunStatus.IsRunning to IsEditorBusy predicate for defense-in- depth (covers manual UI test runs alongside TestJobManager tracking) Co-Authored-By: Claude Opus 4.6 --- .../Editor/Tools/CommandGatewayState.cs | 1 + MCPForUnity/Editor/Tools/CommandQueue.cs | 15 +++- .../Tests/EditMode/Tools/CommandQueueTests.cs | 68 +++++++++++++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) diff --git a/MCPForUnity/Editor/Tools/CommandGatewayState.cs b/MCPForUnity/Editor/Tools/CommandGatewayState.cs index 9812fed04..51fc7059f 100644 --- a/MCPForUnity/Editor/Tools/CommandGatewayState.cs +++ b/MCPForUnity/Editor/Tools/CommandGatewayState.cs @@ -24,6 +24,7 @@ static CommandGatewayState() Queue.IsEditorBusy = () => TestJobManager.HasRunningJob + || TestRunStatus.IsRunning || EditorApplication.isCompiling; // Persist before next domain reload diff --git a/MCPForUnity/Editor/Tools/CommandQueue.cs b/MCPForUnity/Editor/Tools/CommandQueue.cs index 4f2208a5c..1b53c900c 100644 --- a/MCPForUnity/Editor/Tools/CommandQueue.cs +++ b/MCPForUnity/Editor/Tools/CommandQueue.cs @@ -101,7 +101,8 @@ public string GetBlockedReason(string ticket) if (!job.CausesDomainReload) return null; if (!IsEditorBusy()) return null; - if (MCPForUnity.Editor.Services.TestJobManager.HasRunningJob) + if (MCPForUnity.Editor.Services.TestJobManager.HasRunningJob + || MCPForUnity.Editor.Services.TestRunStatus.IsRunning) return "tests_running"; if (UnityEditor.EditorApplication.isCompiling) return "compiling"; @@ -168,7 +169,19 @@ public void ProcessTick(Func> executeCommand) { var heavy = _store.GetJob(_activeHeavyTicket); if (heavy != null && (heavy.Status == JobStatus.Done || heavy.Status == JobStatus.Failed)) + { + // Hold the heavy slot while the editor is still busy from side-effects. + // e.g., run_tests starts tests asynchronously and returns instantly, but + // the TestRunner is still running. We keep the slot occupied so domain-reload + // jobs can't be dequeued until the async operation completes. + if (IsEditorBusy()) + return; + _activeHeavyTicket = null; + // One-frame cooldown: don't immediately dequeue the next heavy job. + // Gives async state one editor frame to settle before the guard check. + return; + } else return; // Heavy still running, wait } diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandQueueTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandQueueTests.cs index a3d9829ac..8c3a00149 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandQueueTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandQueueTests.cs @@ -237,5 +237,73 @@ public void ProcessTick_ReloadJobBehindNonReload_NonReloadProceeds() Assert.That(testJob.Status, Is.Not.EqualTo(JobStatus.Queued)); Assert.That(refreshJob.Status, Is.EqualTo(JobStatus.Queued)); } + + [Test] + public void ProcessTick_HoldsHeavySlotWhileEditorBusy() + { + bool busy = false; + _queue.IsEditorBusy = () => busy; + + var testCmds = new List + { + new() { Tool = "run_tests", Params = new JObject(), Tier = ExecutionTier.Heavy, CausesDomainReload = false } + }; + var refreshCmds = new List + { + new() { Tool = "refresh_unity", Params = new JObject(), Tier = ExecutionTier.Heavy, CausesDomainReload = true } + }; + var testJob = _queue.Submit("a", "tests", false, testCmds); + var refreshJob = _queue.Submit("b", "refresh", false, refreshCmds); + + // Tick 1: starts testJob, completes synchronously + _queue.ProcessTick(DummyExecutor); + Assert.That(testJob.Status, Is.EqualTo(JobStatus.Done)); + Assert.That(_queue.HasActiveHeavy, Is.True, "Heavy slot should remain occupied"); + + // Simulate: tests started async operation, editor is now busy + busy = true; + + // Tick 2: testJob is Done but editor is busy → heavy slot held + _queue.ProcessTick(DummyExecutor); + Assert.That(refreshJob.Status, Is.EqualTo(JobStatus.Queued), "Refresh blocked while editor busy"); + Assert.That(_queue.HasActiveHeavy, Is.True, "Heavy slot held while editor busy"); + + // Tick 3: still busy + _queue.ProcessTick(DummyExecutor); + Assert.That(refreshJob.Status, Is.EqualTo(JobStatus.Queued)); + + // Simulate: tests complete, editor no longer busy + busy = false; + + // Tick 4: heavy slot cleared (cooldown) + _queue.ProcessTick(DummyExecutor); + Assert.That(_queue.HasActiveHeavy, Is.False, "Heavy slot released after editor settles"); + Assert.That(refreshJob.Status, Is.EqualTo(JobStatus.Queued), "Cooldown frame - not dequeued yet"); + + // Tick 5: refresh proceeds + _queue.ProcessTick(DummyExecutor); + Assert.That(refreshJob.Status, Is.EqualTo(JobStatus.Done)); + } + + [Test] + public void ProcessTick_ReleasesHeavySlotImmediatelyWhenEditorNotBusy() + { + _queue.IsEditorBusy = () => false; + + var cmds = new List + { + new() { Tool = "run_tests", Params = new JObject(), Tier = ExecutionTier.Heavy, CausesDomainReload = false } + }; + var job = _queue.Submit("a", "tests", false, cmds); + + // Tick 1: starts and completes job + _queue.ProcessTick(DummyExecutor); + Assert.That(job.Status, Is.EqualTo(JobStatus.Done)); + Assert.That(_queue.HasActiveHeavy, Is.True, "Heavy slot occupied (cooldown pending)"); + + // Tick 2: cooldown frame — clears heavy slot + _queue.ProcessTick(DummyExecutor); + Assert.That(_queue.HasActiveHeavy, Is.False, "Heavy slot released after cooldown"); + } } } From db95bd3302db588c61001712cb8083f37c5b2c0b Mon Sep 17 00:00:00 2001 From: lior Date: Wed, 25 Feb 2026 12:31:27 +0200 Subject: [PATCH 19/33] feat(gateway): add compressed queue_status tool for batch status checks Instant tier (never queued, non-blocking). Returns all tickets with compact field names (t/s/a/l/p/b/e) for token-efficient batch polling. Co-Authored-By: Claude Opus 4.6 --- MCPForUnity/Editor/Tools/CommandQueue.cs | 40 ++++++++++++++++++++++++ MCPForUnity/Editor/Tools/QueueStatus.cs | 9 +++--- MCPForUnity/Editor/Tools/TicketStore.cs | 8 +++++ 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/MCPForUnity/Editor/Tools/CommandQueue.cs b/MCPForUnity/Editor/Tools/CommandQueue.cs index 1b53c900c..ebbf1460b 100644 --- a/MCPForUnity/Editor/Tools/CommandQueue.cs +++ b/MCPForUnity/Editor/Tools/CommandQueue.cs @@ -109,6 +109,46 @@ public string GetBlockedReason(string ticket) return "editor_busy"; } + /// + /// Compressed summary of all jobs for batch status checks. + /// Returns short field names for token efficiency. + /// + public object GetSummary() + { + var allJobs = _store.GetAllJobs(); + var summary = new List(allJobs.Count); + + foreach (var j in allJobs) + { + var entry = new Dictionary + { + ["t"] = j.Ticket, + ["s"] = j.Status.ToString().ToLowerInvariant(), + ["a"] = j.Agent, + ["l"] = j.Label + }; + + if (j.Commands != null && j.Commands.Count > 0) + entry["p"] = $"{j.CurrentIndex + (j.Status == JobStatus.Done ? 0 : 1)}/{j.Commands.Count}"; + + if (j.Status == JobStatus.Queued && j.CausesDomainReload && IsEditorBusy()) + entry["b"] = "blocked"; + + if (j.Status == JobStatus.Failed && j.Error != null) + entry["e"] = j.Error.Length > 80 ? j.Error.Substring(0, 80) + "..." : j.Error; + + summary.Add(entry); + } + + return new + { + jobs = summary, + heavy = _activeHeavyTicket, + qd = QueueDepth, + sf = _smoothInFlight.Count + }; + } + /// /// Get overall queue status. /// diff --git a/MCPForUnity/Editor/Tools/QueueStatus.cs b/MCPForUnity/Editor/Tools/QueueStatus.cs index 63114351c..59ac9b615 100644 --- a/MCPForUnity/Editor/Tools/QueueStatus.cs +++ b/MCPForUnity/Editor/Tools/QueueStatus.cs @@ -4,16 +4,17 @@ namespace MCPForUnity.Editor.Tools { /// - /// Get overall command queue status. No ticket needed. - /// Shows queue depth, active heavy job, smooth in-flight count, per-agent stats. + /// Compressed queue summary — returns all tickets and their status in one shot. + /// Instant tier: never queued, executes inline for non-blocking batch status checks. + /// Fields: t=ticket, s=status, a=agent, l=label, p=progress, b=blocked, e=error. /// [McpForUnityTool("queue_status", Tier = ExecutionTier.Instant)] public static class QueueStatus { public static object HandleCommand(JObject @params) { - var status = CommandGatewayState.Queue.GetStatus(); - return new SuccessResponse("Queue status.", status); + var summary = CommandGatewayState.Queue.GetSummary(); + return new SuccessResponse("Queue snapshot.", summary); } } } diff --git a/MCPForUnity/Editor/Tools/TicketStore.cs b/MCPForUnity/Editor/Tools/TicketStore.cs index 04e1e9506..fb2d57995 100644 --- a/MCPForUnity/Editor/Tools/TicketStore.cs +++ b/MCPForUnity/Editor/Tools/TicketStore.cs @@ -80,6 +80,14 @@ public Dictionary GetAgentStats() public int QueueDepth => _jobs.Values.Count(j => j.Status == JobStatus.Queued); + /// + /// Get all jobs ordered by creation time (most recent first). Used for queue summary. + /// + public List GetAllJobs() + { + return _jobs.Values.OrderByDescending(j => j.CreatedAt).ToList(); + } + /// /// Serialize all jobs to JSON for SessionState persistence. /// From 10cee37a660e4b8a2516df445f324648b5ed2466 Mon Sep 17 00:00:00 2001 From: lior Date: Wed, 25 Feb 2026 12:37:17 +0200 Subject: [PATCH 20/33] docs: add queue summary UI design doc New Queue tab for MCP editor window with real-time job list, status bar, and 1s auto-refresh. UIElements-based section component. Co-Authored-By: Claude Opus 4.6 --- .../2026-02-25-queue-summary-ui-design.md | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 docs/plans/2026-02-25-queue-summary-ui-design.md diff --git a/docs/plans/2026-02-25-queue-summary-ui-design.md b/docs/plans/2026-02-25-queue-summary-ui-design.md new file mode 100644 index 000000000..64e66fc88 --- /dev/null +++ b/docs/plans/2026-02-25-queue-summary-ui-design.md @@ -0,0 +1,75 @@ +# Queue Summary UI — Design + +## Goal + +Add a real-time "Queue" tab to the MCP for Unity editor window that displays the command gateway queue state: active heavy job, queue depth, and a scrollable job list with color-coded status. + +## Architecture + +A new `McpQueueSection` component following the existing section pattern (UXML + USS + C# controller). Integrated as a 6th tab in `MCPForUnityEditorWindow`. Data sourced directly from `CommandGatewayState.Queue` — no network calls, no MCP overhead. + +## Layout + +``` +┌───────────────────────────────────────────────┐ +│ Connect │ Tools │ Resources │ Scripts │ Adv │ Q│ +├───────────────────────────────────────────────┤ +│ Status Bar │ +│ ● Heavy: t-000003 (agent-1: "run tests") │ +│ Queued: 2 │ Running: 1 │ Done: 5 │ +├───────────────────────────────────────────────┤ +│ Job List (ScrollView, most recent first) │ +│ ┌───────────────────────────────────────────┐ │ +│ │ ● t-000006 done agent-2 "refresh" │ │ +│ │ ● t-000005 running agent-1 "tests" 2/5 │ │ +│ │ ● t-000004 queued agent-3 "build" BLK │ │ +│ │ ● t-000003 failed agent-1 "compile" │ │ +│ │ ● t-000002 done agent-2 "find" │ │ +│ └───────────────────────────────────────────┘ │ +└───────────────────────────────────────────────┘ +``` + +## Components + +### Status bar +- Active heavy ticket with agent + label (or "No active heavy job") +- Three counters: Queued / Running / Done+Failed +- Uses existing `.section` and `.setting-row` styles + +### Job list +- One row per `BatchJob` from `TicketStore.GetAllJobs()`, most recent first +- Each row: status dot + ticket (monospace) + status text + agent + label + progress +- Status dot colors: green=done, yellow=running, orange=queued, red=failed, grey=cancelled +- "BLOCKED" badge for queued jobs where `CausesDomainReload && IsEditorBusy()` +- Truncated error tooltip for failed jobs + +### Refresh +- 1-second `EditorApplication.update` timer, independent of the main 2-second throttle +- Only refreshes while Queue tab is visible (lazy — zero overhead on other tabs) +- Calls `Repaint()` when data changes to update the visual tree + +## Data flow + +``` +CommandGatewayState.Queue + → TicketStore.GetAllJobs() → job list rows + → Queue.HasActiveHeavy → status bar heavy ticket + → Queue.QueueDepth → status bar counter + → Queue.SmoothInFlight → status bar counter + → Queue.IsEditorBusy() → blocked badge +``` + +## Files + +| Action | Path | +|--------|------| +| Create | `Editor/Windows/Components/Queue/McpQueueSection.cs` | +| Create | `Editor/Windows/Components/Queue/McpQueueSection.uxml` | +| Create | `Editor/Windows/Components/Queue/McpQueueSection.uss` | +| Modify | `Editor/Windows/MCPForUnityEditorWindow.uxml` — add queue tab + panel | +| Modify | `Editor/Windows/MCPForUnityEditorWindow.cs` — add Queue to ActivePanel enum, wire tab, load section, 1s refresh timer | +| Modify | `Editor/Tools/CommandQueue.cs` — expose `GetAllJobs()` (delegates to TicketStore) | + +## Testing + +Pure editor UI — visual verification only. No unit tests needed. From 9bc09a7b8de45aa8cd9bc35defe554522301356b Mon Sep 17 00:00:00 2001 From: lior Date: Wed, 25 Feb 2026 12:39:42 +0200 Subject: [PATCH 21/33] docs: add queue summary UI implementation plan 6 tasks: expose GetAllJobs, UXML layout, USS styles, C# controller, wire into editor window with 1s refresh, integration verification. Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-02-25-queue-summary-ui-plan.md | 818 ++++++++++++++++++ 1 file changed, 818 insertions(+) create mode 100644 docs/plans/2026-02-25-queue-summary-ui-plan.md diff --git a/docs/plans/2026-02-25-queue-summary-ui-plan.md b/docs/plans/2026-02-25-queue-summary-ui-plan.md new file mode 100644 index 000000000..0c5600fa9 --- /dev/null +++ b/docs/plans/2026-02-25-queue-summary-ui-plan.md @@ -0,0 +1,818 @@ +# Queue Summary UI Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a real-time "Queue" tab to the MCP for Unity editor window showing command gateway queue state with auto-refresh. + +**Architecture:** New `McpQueueSection` UIElements component (UXML + USS + C# controller) integrated as a 6th tab. Data sourced directly from `CommandGatewayState.Queue` with 1-second refresh while visible. Follows the existing section controller pattern (receive root VisualElement, cache elements, expose Refresh). + +**Tech Stack:** Unity UIElements (UXML/USS), C# EditorWindow, CommandQueue/TicketStore APIs + +**Design doc:** `docs/plans/2026-02-25-queue-summary-ui-design.md` + +--- + +### Task 1: Expose GetAllJobs on CommandQueue + +**Files:** +- Modify: `MCPForUnity/Editor/Tools/CommandQueue.cs` + +**Context:** `TicketStore.GetAllJobs()` already exists (added in the queue_status commit). CommandQueue needs a public method that delegates to it so the UI section can read job data without reaching into the store directly. + +**Step 1: Add GetAllJobs to CommandQueue** + +In `MCPForUnity/Editor/Tools/CommandQueue.cs`, add after the `GetSummary()` method (around line 150): + +```csharp +/// +/// Get all jobs ordered by creation time (most recent first). +/// +public List GetAllJobs() => _store.GetAllJobs(); +``` + +**Step 2: Compile** + +``` +refresh_unity(scope=all, compile=request, wait_for_ready=true) +read_console(types=["error"]) → 0 errors +``` + +**Step 3: Commit** + +```bash +git add MCPForUnity/Editor/Tools/CommandQueue.cs +git commit -m "feat(gateway): expose GetAllJobs on CommandQueue for UI consumption" +``` + +--- + +### Task 2: Create McpQueueSection UXML layout + +**Files:** +- Create: `MCPForUnity/Editor/Windows/Components/Queue/McpQueueSection.uxml` + +**Context:** This defines the visual tree for the Queue tab. It has two sections: a status bar with counters, and a scrollable container where job rows are dynamically added by the C# controller. + +The UXML follows the same structure as `McpToolsSection.uxml` — a single `.section` wrapper with `.section-title` and `.section-content`. + +**Step 1: Create the UXML file** + +Create `MCPForUnity/Editor/Windows/Components/Queue/McpQueueSection.uxml`: + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +**Step 2: Commit** + +```bash +git add MCPForUnity/Editor/Windows/Components/Queue/McpQueueSection.uxml +git commit -m "feat(gateway-ui): add McpQueueSection UXML layout" +``` + +--- + +### Task 3: Create McpQueueSection USS styles + +**Files:** +- Create: `MCPForUnity/Editor/Windows/Components/Queue/McpQueueSection.uss` + +**Context:** Custom styles for the queue tab. Status dot colors, counter row layout, job row grid, monospace ticket text. Reuses existing `.section`, `.section-title`, `.section-content`, `.setting-row`, `.setting-label`, `.setting-value`, `.help-text` from `Common.uss`. + +**Step 1: Create the USS file** + +Create `MCPForUnity/Editor/Windows/Components/Queue/McpQueueSection.uss`: + +```css +/* Status bar */ +.queue-status-bar { + margin-bottom: 8px; + padding-bottom: 8px; + border-bottom-width: 1px; + border-bottom-color: rgba(255, 255, 255, 0.08); +} + +/* Counter row */ +.queue-counters { + flex-direction: row; + margin-top: 4px; +} + +.queue-counter { + font-size: 11px; + margin-right: 16px; + color: rgba(180, 180, 180, 1); +} + +/* Job list header */ +.queue-job-header { + flex-direction: row; + padding: 4px 8px; + margin-bottom: 2px; + border-bottom-width: 1px; + border-bottom-color: rgba(255, 255, 255, 0.06); +} + +.queue-job-header > Label { + font-size: 10px; + -unity-font-style: bold; + color: rgba(150, 150, 150, 1); +} + +/* Job list container */ +.queue-job-list { + flex-direction: column; +} + +/* Individual job row */ +.queue-job-row { + flex-direction: row; + align-items: center; + padding: 3px 8px; + margin-bottom: 1px; + border-radius: 2px; +} + +.queue-job-row:hover { + background-color: rgba(255, 255, 255, 0.04); +} + +/* Column widths */ +.queue-col-ticket { + width: 72px; + min-width: 72px; + font-size: 11px; + -unity-font-style: bold; + overflow: hidden; +} + +.queue-col-status { + width: 70px; + min-width: 70px; + font-size: 11px; +} + +.queue-col-agent { + width: 80px; + min-width: 80px; + font-size: 11px; + overflow: hidden; +} + +.queue-col-label { + flex-grow: 1; + font-size: 11px; + overflow: hidden; +} + +.queue-col-progress { + width: 50px; + min-width: 50px; + font-size: 11px; + -unity-text-align: middle-right; +} + +/* Status dot in job rows */ +.queue-status-dot { + width: 8px; + height: 8px; + border-radius: 4px; + margin-right: 6px; + flex-shrink: 0; +} + +.queue-status-dot.status-queued { + background-color: rgba(255, 180, 0, 1); +} + +.queue-status-dot.status-running { + background-color: rgba(100, 200, 255, 1); +} + +.queue-status-dot.status-done { + background-color: rgba(0, 200, 100, 1); +} + +.queue-status-dot.status-failed { + background-color: rgba(200, 50, 50, 1); +} + +.queue-status-dot.status-cancelled { + background-color: rgba(150, 150, 150, 1); +} + +/* Blocked badge */ +.queue-blocked-badge { + font-size: 9px; + padding: 1px 4px; + margin-left: 4px; + background-color: rgba(255, 100, 50, 0.3); + border-radius: 3px; + color: rgba(255, 150, 100, 1); +} + +/* Light theme overrides */ +.unity-theme-light .queue-status-bar { + border-bottom-color: rgba(0, 0, 0, 0.1); +} + +.unity-theme-light .queue-counter { + color: rgba(80, 80, 80, 1); +} + +.unity-theme-light .queue-job-header { + border-bottom-color: rgba(0, 0, 0, 0.08); +} + +.unity-theme-light .queue-job-header > Label { + color: rgba(100, 100, 100, 1); +} + +.unity-theme-light .queue-job-row:hover { + background-color: rgba(0, 0, 0, 0.04); +} + +.unity-theme-light .queue-blocked-badge { + background-color: rgba(255, 100, 50, 0.2); + color: rgba(200, 80, 30, 1); +} +``` + +**Step 2: Commit** + +```bash +git add MCPForUnity/Editor/Windows/Components/Queue/McpQueueSection.uss +git commit -m "feat(gateway-ui): add McpQueueSection USS styles" +``` + +--- + +### Task 4: Create McpQueueSection C# controller + +**Files:** +- Create: `MCPForUnity/Editor/Windows/Components/Queue/McpQueueSection.cs` + +**Context:** This is the main controller. It follows the same pattern as `McpToolsSection`: +1. Constructor receives root VisualElement +2. `CacheUIElements()` via `Q(name)` +3. `Refresh()` reads queue data and rebuilds the job list + +Data source: `CommandGatewayState.Queue` — specifically `GetAllJobs()` for the job list, `HasActiveHeavy` and `QueueDepth` for the status bar. + +**Step 1: Create the C# controller** + +Create `MCPForUnity/Editor/Windows/Components/Queue/McpQueueSection.cs`: + +```csharp +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.Tools; +using UnityEngine.UIElements; + +namespace MCPForUnity.Editor.Windows.Components.Queue +{ + /// + /// Controller for the Queue tab in the MCP For Unity editor window. + /// Displays real-time command gateway queue state. + /// + public class McpQueueSection + { + private Label heavyTicketLabel; + private Label queuedCount; + private Label runningCount; + private Label doneCount; + private Label failedCount; + private VisualElement jobListContainer; + private VisualElement jobListHeader; + private Label emptyLabel; + + public VisualElement Root { get; private set; } + + public McpQueueSection(VisualElement root) + { + Root = root; + CacheUIElements(); + } + + private void CacheUIElements() + { + heavyTicketLabel = Root.Q