diff --git a/.agents/skills/uloop-control-play-mode/SKILL.md b/.agents/skills/uloop-control-play-mode/SKILL.md index 929537ee68..6f18608780 100644 --- a/.agents/skills/uloop-control-play-mode/SKILL.md +++ b/.agents/skills/uloop-control-play-mode/SKILL.md @@ -32,6 +32,8 @@ Returns JSON with the current play mode state: - `ResumedFromPause`: Whether `Play` resumed a paused Play Mode session instead of starting a new one - `Message`: Description of the action performed - `Warning`: Set when the action carries a caveat. A fresh `Play` start always notes that the session started from Edit-time scene state; additionally, when active hot-reload patches or enabled pause points exist and Domain Reload is enabled, it reports how many of them the Play-entry domain reload will discard. +- `StoppedBy` (string, optional): Why Play Mode last stopped: `cli-control-play-mode`, `cli-compile-stop-setting`, `cli-run-tests-cancel`, `script-compilation`, or `unknown`. Present on `Stop` when Play Mode was already stopped, and on `Status` when Play Mode is not running. Omitted when this Editor session has no confirmed stop. +- `StoppedAt` (string, optional): UTC ISO 8601 timestamp of that stop. Omitted together with `StoppedBy` when no stop is recorded. ## Notes diff --git a/.claude/skills/uloop-control-play-mode/SKILL.md b/.claude/skills/uloop-control-play-mode/SKILL.md index 929537ee68..6f18608780 100644 --- a/.claude/skills/uloop-control-play-mode/SKILL.md +++ b/.claude/skills/uloop-control-play-mode/SKILL.md @@ -32,6 +32,8 @@ Returns JSON with the current play mode state: - `ResumedFromPause`: Whether `Play` resumed a paused Play Mode session instead of starting a new one - `Message`: Description of the action performed - `Warning`: Set when the action carries a caveat. A fresh `Play` start always notes that the session started from Edit-time scene state; additionally, when active hot-reload patches or enabled pause points exist and Domain Reload is enabled, it reports how many of them the Play-entry domain reload will discard. +- `StoppedBy` (string, optional): Why Play Mode last stopped: `cli-control-play-mode`, `cli-compile-stop-setting`, `cli-run-tests-cancel`, `script-compilation`, or `unknown`. Present on `Stop` when Play Mode was already stopped, and on `Status` when Play Mode is not running. Omitted when this Editor session has no confirmed stop. +- `StoppedAt` (string, optional): UTC ISO 8601 timestamp of that stop. Omitted together with `StoppedBy` when no stop is recorded. ## Notes diff --git a/Assets/Tests/Editor/ControlPlayModeStoppedByTests.cs b/Assets/Tests/Editor/ControlPlayModeStoppedByTests.cs new file mode 100644 index 0000000000..dbb8343d0b --- /dev/null +++ b/Assets/Tests/Editor/ControlPlayModeStoppedByTests.cs @@ -0,0 +1,200 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using UnityEditor; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Tests that control-play-mode copies confirmed stop reasons onto Stop and Status responses. + /// + public sealed class ControlPlayModeStoppedByTests + { + [SetUp] + public void SetUp() + { + PlayModeStopReasonSessionStore.ClearForTests(); + } + + [TearDown] + public void TearDown() + { + PlayModeStopReasonSessionStore.ClearForTests(); + } + + /// + /// What: Stop while already stopped copies the confirmed SessionState reason onto the response. + /// + [Test] + public async Task ExecuteAsync_WhenStopAlreadyStoppedAndReasonConfirmed_CopiesStoppedByAndStoppedAt() + { + Assert.That(EditorApplication.isPlaying, Is.False); + PlayModeStopReasonSessionStore.SetPending("cli-control-play-mode"); + PlayModeStopReasonSessionStore.ConfirmPending("2026-01-01T00:00:00.0000000Z"); + ControlPlayModeUseCase useCase = new ControlPlayModeUseCase( + compilationFailureProvider: new EmptyCompilationFailureProvider(), + compilationFailureGate: new OpenCompilationFailureGate()); + ControlPlayModeSchema schema = new ControlPlayModeSchema + { + Action = PlayModeAction.Stop + }; + + ControlPlayModeResponse response = await useCase.ExecuteAsync(schema, CancellationToken.None); + + Assert.That(response.WasAlreadyStopped, Is.True); + Assert.That(response.StoppedBy, Is.EqualTo("cli-control-play-mode")); + Assert.That(response.StoppedAt, Is.EqualTo("2026-01-01T00:00:00.0000000Z")); + } + + /// + /// What: Status while Play Mode is stopped copies the confirmed SessionState reason onto the response. + /// + [Test] + public async Task ExecuteAsync_WhenStatusWhileStoppedAndReasonConfirmed_CopiesStoppedByAndStoppedAt() + { + Assert.That(EditorApplication.isPlaying, Is.False); + PlayModeStopReasonSessionStore.SetPending("cli-compile-stop-setting"); + PlayModeStopReasonSessionStore.ConfirmPending("2026-01-03T00:00:00.0000000Z"); + ControlPlayModeUseCase useCase = new ControlPlayModeUseCase( + compilationFailureProvider: new EmptyCompilationFailureProvider(), + compilationFailureGate: new OpenCompilationFailureGate()); + ControlPlayModeSchema schema = new ControlPlayModeSchema + { + Action = PlayModeAction.Status + }; + + ControlPlayModeResponse response = await useCase.ExecuteAsync(schema, CancellationToken.None); + + Assert.That(response.IsPlaying, Is.False); + Assert.That(response.StoppedBy, Is.EqualTo("cli-compile-stop-setting")); + Assert.That(response.StoppedAt, Is.EqualTo("2026-01-03T00:00:00.0000000Z")); + } + + /// + /// What: Stop while already stopped omits StoppedBy when SessionState has no confirmed reason. + /// + [Test] + public async Task ExecuteAsync_WhenStopAlreadyStoppedAndNoRecord_LeavesStoppedByNull() + { + Assert.That(EditorApplication.isPlaying, Is.False); + ControlPlayModeUseCase useCase = new ControlPlayModeUseCase( + compilationFailureProvider: new EmptyCompilationFailureProvider(), + compilationFailureGate: new OpenCompilationFailureGate()); + ControlPlayModeSchema schema = new ControlPlayModeSchema + { + Action = PlayModeAction.Stop + }; + + ControlPlayModeResponse response = await useCase.ExecuteAsync(schema, CancellationToken.None); + + Assert.That(response.WasAlreadyStopped, Is.True); + Assert.That(response.StoppedBy, Is.Null); + Assert.That(response.StoppedAt, Is.Null); + } + + /// + /// What: serialized JSON omits StoppedBy and StoppedAt when they are null, and includes them when set. + /// + [Test] + public void ControlPlayModeResponse_WhenSerialized_OmitsNullStoppedByAndStoppedAtKeys() + { + ControlPlayModeResponse omitted = new ControlPlayModeResponse + { + Message = "Play mode was already stopped", + Warning = string.Empty, + CompileErrors = Array.Empty() + }; + JObject omittedJson = JObject.Parse( + JsonConvert.SerializeObject( + omitted, + Formatting.None, + UnityCliLoopJsonResponseSerializerSettings.Settings)); + Assert.That(omittedJson.Property("StoppedBy"), Is.Null); + Assert.That(omittedJson.Property("StoppedAt"), Is.Null); + + ControlPlayModeResponse populated = new ControlPlayModeResponse + { + Message = "Play mode was already stopped", + Warning = string.Empty, + CompileErrors = Array.Empty(), + StoppedBy = "cli-control-play-mode", + StoppedAt = "2026-01-01T00:00:00.0000000Z" + }; + JObject populatedJson = LoadJsonWithoutDateParsing( + JsonConvert.SerializeObject( + populated, + Formatting.None, + UnityCliLoopJsonResponseSerializerSettings.Settings)); + Assert.That(populatedJson["StoppedBy"]?.Value(), Is.EqualTo("cli-control-play-mode")); + Assert.That(populatedJson["StoppedAt"]?.Value(), Is.EqualTo("2026-01-01T00:00:00.0000000Z")); + } + + /// + /// What: Stop with a changing stop does not copy StoppedBy even when a record exists. + /// + [Test] + public async Task ExecuteAsync_WhenStopWhilePlaying_DoesNotCopyStoppedBy() + { + PlayModeStopReasonSessionStore.SetPending("script-compilation"); + PlayModeStopReasonSessionStore.ConfirmPending("2026-01-04T00:00:00.0000000Z"); + FakePlayingEditorStateService editorState = new FakePlayingEditorStateService(); + ControlPlayModeUseCase useCase = new ControlPlayModeUseCase( + compilationFailureProvider: new EmptyCompilationFailureProvider(), + compilationFailureGate: new OpenCompilationFailureGate(), + editorStateService: editorState); + ControlPlayModeSchema schema = new ControlPlayModeSchema + { + Action = PlayModeAction.Stop + }; + + ControlPlayModeResponse response = await useCase.ExecuteAsync(schema, CancellationToken.None); + + Assert.That(response.WasAlreadyStopped, Is.False); + Assert.That(response.StoppedBy, Is.Null); + Assert.That(response.StoppedAt, Is.Null); + } + + private static JObject LoadJsonWithoutDateParsing(string json) + { + using (StringReader stringReader = new StringReader(json)) + using (JsonTextReader jsonReader = new JsonTextReader(stringReader)) + { + jsonReader.DateParseHandling = DateParseHandling.None; + return JObject.Load(jsonReader); + } + } + + private sealed class EmptyCompilationFailureProvider : IControlPlayModeCompilationFailureProvider + { + public ControlPlayModeCompileError[] GetLastFailedErrors() + { + return Array.Empty(); + } + } + + private sealed class OpenCompilationFailureGate : IControlPlayModeCompilationFailureGate + { + public bool HasScriptCompilationFailed() + { + return false; + } + } + + private sealed class FakePlayingEditorStateService : IControlPlayModeEditorStateService + { + public bool IsPlaying { get; set; } = true; + public bool IsPaused { get; set; } + + public void Step() + { + } + } + } +} diff --git a/Assets/Tests/Editor/ControlPlayModeStoppedByTests.cs.meta b/Assets/Tests/Editor/ControlPlayModeStoppedByTests.cs.meta new file mode 100644 index 0000000000..64833b59a4 --- /dev/null +++ b/Assets/Tests/Editor/ControlPlayModeStoppedByTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fa0a56f822ee64f1f8610b127f67227d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/PlayModeStopReasonSessionStoreTests.cs b/Assets/Tests/Editor/PlayModeStopReasonSessionStoreTests.cs new file mode 100644 index 0000000000..7a8e355c19 --- /dev/null +++ b/Assets/Tests/Editor/PlayModeStopReasonSessionStoreTests.cs @@ -0,0 +1,410 @@ +using System; +using System.Reflection; +using System.Text.RegularExpressions; +using NUnit.Framework; +using UnityEditor; +using UnityEditor.Compilation; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Tests pending-priority and unknown fallback for Play Mode stop reasons. + /// + public sealed class PlayModeStopReasonSessionStoreTests + { + [SetUp] + public void SetUp() + { + PlayModeStopReasonSessionStore.ClearForTests(); + } + + [TearDown] + public void TearDown() + { + PlayModeStopReasonSessionStore.ClearForTests(); + } + + /// + /// What: SetPending overwrites a previous pending reason, including script-compilation. + /// + [Test] + public void SetPending_WhenCalledAfterTrySetPending_OverwritesScriptCompilation() + { + PlayModeStopReasonSessionStore.TrySetPending("script-compilation"); + PlayModeStopReasonSessionStore.SetPending("cli-control-play-mode"); + + Assert.That(PlayModeStopReasonSessionStore.PendingReason, Is.EqualTo("cli-control-play-mode")); + } + + /// + /// What: TrySetPending leaves an explicit pending reason in place. + /// + [Test] + public void TrySetPending_WhenPendingAlreadySet_DoesNotOverwrite() + { + PlayModeStopReasonSessionStore.SetPending("cli-compile-stop-setting"); + PlayModeStopReasonSessionStore.TrySetPending("script-compilation"); + + Assert.That(PlayModeStopReasonSessionStore.PendingReason, Is.EqualTo("cli-compile-stop-setting")); + } + + /// + /// What: ConfirmPending with no pending reason stores unknown plus the given timestamp. + /// + [Test] + public void ConfirmPending_WhenNoPendingReason_StoresUnknown() + { + PlayModeStopReasonSessionStore.ConfirmPending("2026-01-01T00:00:00.0000000Z"); + + PlayModeStopReasonRecord record = PlayModeStopReasonSessionStore.TryReadConfirmed(); + Assert.That(record.HasValue, Is.True); + Assert.That(record.StoppedBy, Is.EqualTo("unknown")); + Assert.That(record.StoppedAtUtc, Is.EqualTo("2026-01-01T00:00:00.0000000Z")); + Assert.That(PlayModeStopReasonSessionStore.PendingReason, Is.Null); + } + + /// + /// What: ConfirmPending writes the pending reason and clears pending. + /// + [Test] + public void ConfirmPending_WhenPendingIsSet_StoresThatReason() + { + PlayModeStopReasonSessionStore.SetPending("cli-run-tests-cancel"); + PlayModeStopReasonSessionStore.ConfirmPending("2026-01-02T00:00:00.0000000Z"); + + PlayModeStopReasonRecord record = PlayModeStopReasonSessionStore.TryReadConfirmed(); + Assert.That(record.StoppedBy, Is.EqualTo("cli-run-tests-cancel")); + Assert.That(record.StoppedAtUtc, Is.EqualTo("2026-01-02T00:00:00.0000000Z")); + Assert.That(PlayModeStopReasonSessionStore.PendingReason, Is.Null); + } + + /// + /// What: ConfirmPending writes the reason and timestamp into SessionState under the wire keys. + /// + [Test] + public void ConfirmPending_WhenCalled_WritesLiteralSessionStateKeys() + { + PlayModeStopReasonSessionStore.SetPending("cli-control-play-mode"); + PlayModeStopReasonSessionStore.ConfirmPending("2026-01-05T00:00:00.0000000Z"); + + Assert.That( + SessionState.GetString("io.github.hatayama.uloopmcp.playModeStopReason.reason", string.Empty), + Is.EqualTo("cli-control-play-mode")); + Assert.That( + SessionState.GetString( + "io.github.hatayama.uloopmcp.playModeStopReason.stoppedAtUtc", + string.Empty), + Is.EqualTo("2026-01-05T00:00:00.0000000Z")); + } + + /// + /// What: TryReadConfirmed returns values previously stored under the SessionState wire keys. + /// + [Test] + public void TryReadConfirmed_WhenSessionStateSeededWithLiteralKeys_ReturnsThoseValues() + { + SessionState.SetString( + "io.github.hatayama.uloopmcp.playModeStopReason.reason", + "script-compilation"); + SessionState.SetString( + "io.github.hatayama.uloopmcp.playModeStopReason.stoppedAtUtc", + "2026-01-06T00:00:00.0000000Z"); + + PlayModeStopReasonRecord record = PlayModeStopReasonSessionStore.TryReadConfirmed(); + + Assert.That(record.StoppedBy, Is.EqualTo("script-compilation")); + Assert.That(record.StoppedAtUtc, Is.EqualTo("2026-01-06T00:00:00.0000000Z")); + } + } + + /// + /// Tests that each Play Mode stop input path stamps the matching pending reason. + /// + public sealed class PlayModeStopReasonWiringTests + { + [SetUp] + public void SetUp() + { + PlayModeStopReasonSessionStore.ClearForTests(); + } + + [TearDown] + public void TearDown() + { + PlayModeStopReasonSessionStore.ClearForTests(); + } + + /// + /// What: setting IsPlaying false on the editor state service stamps cli-control-play-mode. + /// + [Test] + public void EditorStateService_WhenIsPlayingSetFalse_SetsPendingCliControlPlayMode() + { + ControlPlayModeEditorStateService service = new ControlPlayModeEditorStateService(); + + service.IsPlaying = false; + + Assert.That( + PlayModeStopReasonSessionStore.PendingReason, + Is.EqualTo("cli-control-play-mode")); + } + + /// + /// What: compile StopPlayMode stamps cli-compile-stop-setting before exiting Play Mode. + /// + [Test] + public void StopPlayMode_WhenInvoked_SetsPendingCliCompileStopSetting() + { + PlayModeCompilationPreparationService service = new PlayModeCompilationPreparationService(); + + service.StopPlayMode(); + + Assert.That( + PlayModeStopReasonSessionStore.PendingReason, + Is.EqualTo("cli-compile-stop-setting")); + } + + /// + /// What: run-tests cancel exit stamps cli-run-tests-cancel. + /// + [Test] + public void StopPlayingForCancel_WhenInvoked_SetsPendingCliRunTestsCancel() + { + RunTestsCancelStopRestoreUnityHooks.StopPlayingForCancel(); + + Assert.That( + PlayModeStopReasonSessionStore.PendingReason, + Is.EqualTo("cli-run-tests-cancel")); + } + + /// + /// What: compilationStarted stamps script-compilation only when pending is empty. + /// + [Test] + public void HandleCompilationStarted_WhenPendingEmpty_SetsScriptCompilation() + { + PlayModeStopReasonSubscriber.HandleCompilationStarted(null); + + Assert.That( + PlayModeStopReasonSessionStore.PendingReason, + Is.EqualTo("script-compilation")); + } + + /// + /// What: compilationStarted does not replace an explicit pending reason. + /// + [Test] + public void HandleCompilationStarted_WhenPendingAlreadySet_DoesNotOverwrite() + { + PlayModeStopReasonSessionStore.SetPending("cli-control-play-mode"); + + PlayModeStopReasonSubscriber.HandleCompilationStarted(null); + + Assert.That( + PlayModeStopReasonSessionStore.PendingReason, + Is.EqualTo("cli-control-play-mode")); + } + + /// + /// What: ExitingPlayMode with no pending confirms unknown. + /// + [Test] + public void HandlePlayModeStateChanged_WhenExitingPlayModeWithoutPending_ConfirmsUnknown() + { + PlayModeStopReasonSubscriber.HandlePlayModeStateChanged(PlayModeStateChange.ExitingPlayMode); + + PlayModeStopReasonRecord record = PlayModeStopReasonSessionStore.TryReadConfirmed(); + Assert.That(record.StoppedBy, Is.EqualTo("unknown")); + Assert.That( + Regex.IsMatch( + record.StoppedAtUtc, + "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{7}Z$"), + Is.True, + record.StoppedAtUtc); + Assert.That(PlayModeStopReasonSessionStore.PendingReason, Is.Null); + } + + /// + /// What: compilationFinished clears a leftover script-compilation fallback pending. + /// + [Test] + public void HandleCompilationFinished_WhenPendingIsScriptCompilation_ClearsPending() + { + PlayModeStopReasonSessionStore.TrySetPending("script-compilation"); + + PlayModeStopReasonSubscriber.HandleCompilationFinished(null); + + Assert.That(PlayModeStopReasonSessionStore.PendingReason, Is.Null); + } + + /// + /// What: compilationFinished leaves an explicit CLI pending reason in place. + /// + [Test] + public void HandleCompilationFinished_WhenPendingIsExplicitReason_LeavesPending() + { + PlayModeStopReasonSessionStore.SetPending("cli-compile-stop-setting"); + + PlayModeStopReasonSubscriber.HandleCompilationFinished(null); + + Assert.That( + PlayModeStopReasonSessionStore.PendingReason, + Is.EqualTo("cli-compile-stop-setting")); + } + + /// + /// What: compilationFinished with no pending does not invent a pending reason. + /// + [Test] + public void HandleCompilationFinished_WhenNoPending_RemainsNoPending() + { + PlayModeStopReasonSubscriber.HandleCompilationFinished(null); + + Assert.That(PlayModeStopReasonSessionStore.PendingReason, Is.Null); + } + + /// + /// What: editor startup subscribed the production compilation and play-mode handlers. + /// + [Test] + public void InitializeForEditorStartup_WhenEditorIsRunning_SubscribesProductionHandlers() + { + Assert.That( + StaticEventHasHandler( + typeof(CompilationPipeline), + nameof(PlayModeStopReasonSubscriber.HandleCompilationStarted)), + Is.True, + "HandleCompilationStarted must be subscribed on CompilationPipeline."); + Assert.That( + StaticEventHasHandler( + typeof(CompilationPipeline), + nameof(PlayModeStopReasonSubscriber.HandleCompilationFinished)), + Is.True, + "HandleCompilationFinished must be subscribed on CompilationPipeline."); + Assert.That( + StaticEventHasHandler( + typeof(EditorApplication), + nameof(PlayModeStopReasonSubscriber.HandlePlayModeStateChanged)), + Is.True, + "HandlePlayModeStateChanged must be subscribed on EditorApplication."); + } + + /// + /// What: a non-exit play-mode event does not confirm pending. + /// + [Test] + public void HandlePlayModeStateChanged_WhenNotExitingPlayMode_LeavesPending() + { + PlayModeStopReasonSessionStore.SetPending("cli-control-play-mode"); + + PlayModeStopReasonSubscriber.HandlePlayModeStateChanged(PlayModeStateChange.EnteredPlayMode); + + Assert.That(PlayModeStopReasonSessionStore.PendingReason, Is.EqualTo("cli-control-play-mode")); + Assert.That(PlayModeStopReasonSessionStore.TryReadConfirmed().HasValue, Is.False); + } + + // Why: CompilationPipeline stores handlers on Delegate fields, but + // EditorApplication.playModeStateChanged lives on EventWithPerformanceTracker + // (m_PlayModeStateChangedEvent). A Delegate-only scan cannot see it. + private static bool StaticEventHasHandler(Type eventOwner, string handlerName) + { + FieldInfo[] fields = eventOwner.GetFields( + BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic); + for (int index = 0; index < fields.Length; index++) + { + object value = fields[index].GetValue(null); + if (ContainsProductionHandler(value, handlerName)) + { + return true; + } + } + + return false; + } + + private static bool ContainsProductionHandler(object source, string handlerName) + { + if (source == null) + { + return false; + } + + Delegate current = source as Delegate; + if (current != null) + { + return InvocationListContains(current, handlerName); + } + + return EnumeratorContainsHandler(source, handlerName); + } + + private static bool InvocationListContains(Delegate current, string handlerName) + { + Delegate[] listeners = current.GetInvocationList(); + for (int listenerIndex = 0; listenerIndex < listeners.Length; listenerIndex++) + { + if (IsProductionHandler(listeners[listenerIndex], handlerName)) + { + return true; + } + } + + return false; + } + + private static bool EnumeratorContainsHandler(object source, string handlerName) + { + string typeName = source.GetType().Name; + if (typeName.IndexOf("EventWithPerformanceTracker", StringComparison.Ordinal) < 0) + { + return false; + } + + MethodInfo getEnumerator = source.GetType().GetMethod( + "GetEnumerator", + BindingFlags.Instance | BindingFlags.Public); + if (getEnumerator == null || getEnumerator.GetParameters().Length != 0) + { + return false; + } + + object enumerator = getEnumerator.Invoke(source, null); + if (enumerator == null) + { + return false; + } + + MethodInfo moveNext = enumerator.GetType().GetMethod("MoveNext"); + PropertyInfo currentProperty = enumerator.GetType().GetProperty("Current"); + if (moveNext == null || currentProperty == null) + { + return false; + } + + while ((bool)moveNext.Invoke(enumerator, null)) + { + Delegate listener = currentProperty.GetValue(enumerator) as Delegate; + if (IsProductionHandler(listener, handlerName)) + { + return true; + } + } + + return false; + } + + private static bool IsProductionHandler(Delegate listener, string handlerName) + { + if (listener == null) + { + return false; + } + + MethodInfo listenerMethod = listener.Method; + return listenerMethod.DeclaringType == typeof(PlayModeStopReasonSubscriber) + && listenerMethod.Name == handlerName; + } + } +} diff --git a/Assets/Tests/Editor/PlayModeStopReasonSessionStoreTests.cs.meta b/Assets/Tests/Editor/PlayModeStopReasonSessionStoreTests.cs.meta new file mode 100644 index 0000000000..5278d37fc6 --- /dev/null +++ b/Assets/Tests/Editor/PlayModeStopReasonSessionStoreTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 88e98ead50d6b43819415c51cfeafc6d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Compile/PlayModeCompilationPreparationService.cs b/Packages/src/Editor/FirstPartyTools/Compile/PlayModeCompilationPreparationService.cs index 20bba10d9c..30eecb7328 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/PlayModeCompilationPreparationService.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/PlayModeCompilationPreparationService.cs @@ -50,6 +50,9 @@ public void StopPlayMode() { EditorApplication.isPaused = false; } + + PlayModeStopReasonSessionStore.SetPending( + ControlPlayModeConstants.StoppedByCliCompileStopSetting); EditorApplication.isPlaying = false; } } diff --git a/Packages/src/Editor/FirstPartyTools/Compile/UnityCLILoop.FirstPartyTools.Compile.Editor.asmdef b/Packages/src/Editor/FirstPartyTools/Compile/UnityCLILoop.FirstPartyTools.Compile.Editor.asmdef index a220a2bc0a..fcbd1bca7e 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/UnityCLILoop.FirstPartyTools.Compile.Editor.asmdef +++ b/Packages/src/Editor/FirstPartyTools/Compile/UnityCLILoop.FirstPartyTools.Compile.Editor.asmdef @@ -6,7 +6,8 @@ "GUID:e5952ef560e1641f68b2687e6045bf5b", "GUID:d427b32aad9cb44fc8e962437c9dbcd8", "GUID:fc3fd32eddbee40e39c2d76dc184957b", - "GUID:527f26a36b5043c2bd4d4036d04cd76d" + "GUID:527f26a36b5043c2bd4d4036d04cd76d", + "GUID:0cf5a90ce140e4729abd701bea48b00e" ], "includePlatforms": [ "Editor" diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/AssemblyInfo.cs b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/AssemblyInfo.cs index e3c0a75669..935f947cfd 100644 --- a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/AssemblyInfo.cs +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/AssemblyInfo.cs @@ -1,4 +1,6 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.Editor")] +[assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.Compile.Editor")] +[assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.RunTests.TestFramework.Editor")] [assembly: InternalsVisibleTo("UnityCLILoop.Tests.Editor")] diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeConstants.cs b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeConstants.cs new file mode 100644 index 0000000000..29154e236d --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeConstants.cs @@ -0,0 +1,14 @@ +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// User-facing stop-reason values for control-play-mode StoppedBy. + /// + internal static class ControlPlayModeConstants + { + internal const string StoppedByCliControlPlayMode = "cli-control-play-mode"; + internal const string StoppedByCliCompileStopSetting = "cli-compile-stop-setting"; + internal const string StoppedByCliRunTestsCancel = "cli-run-tests-cancel"; + internal const string StoppedByScriptCompilation = "script-compilation"; + internal const string StoppedByUnknown = "unknown"; + } +} diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeConstants.cs.meta b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeConstants.cs.meta new file mode 100644 index 0000000000..2aec7dc5c6 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeConstants.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b15189c0d193f484db4f9a870a94e8ea +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeEditorStartup.cs b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeEditorStartup.cs index 4cc9eecf6c..2883cf8474 100644 --- a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeEditorStartup.cs +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeEditorStartup.cs @@ -8,6 +8,7 @@ internal static class ControlPlayModeEditorStartup internal static void Initialize() { ControlPlayModeServices.InitializeForEditorStartup(); + PlayModeStopReasonSubscriber.InitializeForEditorStartup(); PlayModeFocusSuppressionStartup.Initialize(); } } diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeEditorStateService.cs b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeEditorStateService.cs index 6e73d7eb0c..a316ed80c3 100644 --- a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeEditorStateService.cs +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeEditorStateService.cs @@ -20,7 +20,16 @@ internal sealed class ControlPlayModeEditorStateService : IControlPlayModeEditor public bool IsPlaying { get => EditorApplication.isPlaying; - set => EditorApplication.isPlaying = value; + set + { + if (!value) + { + PlayModeStopReasonSessionStore.SetPending( + ControlPlayModeConstants.StoppedByCliControlPlayMode); + } + + EditorApplication.isPlaying = value; + } } public bool IsPaused diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeResponse.cs b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeResponse.cs index dbc5e0e05d..0566ec5dca 100644 --- a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeResponse.cs +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeResponse.cs @@ -1,3 +1,5 @@ +using Newtonsoft.Json; + using io.github.hatayama.UnityCliLoop.ToolContracts; namespace io.github.hatayama.UnityCliLoop.FirstPartyTools @@ -18,5 +20,17 @@ public class ControlPlayModeResponse : UnityCliLoopToolResponse public ControlPlayModeCompileError[] CompileErrors { get; set; } public string Message { get; set; } public string Warning { get; set; } = string.Empty; + + /// + /// Why Play Mode last stopped. Omitted when this Editor session has no confirmed stop. + /// + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public string StoppedBy { get; set; } + + /// + /// UTC ISO 8601 timestamp of the last Play Mode stop. Omitted with StoppedBy when none is recorded. + /// + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public string StoppedAt { get; set; } } } diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeUseCase.cs b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeUseCase.cs index de28b9709c..8d7a5483c9 100644 --- a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeUseCase.cs @@ -76,7 +76,8 @@ public Task ExecuteAsync(ControlPlayModeSchema paramete actionResult.Changed, actionResult.WasAlreadyStopped, actionResult.ResumedFromPause, - actionResult.Warning)); + actionResult.Warning, + parameters.Action)); } private ControlPlayModeResponse CreateStatusOnlyResponse(ControlPlayModeSchema parameters) @@ -88,7 +89,7 @@ private ControlPlayModeResponse CreateStatusOnlyResponse(ControlPlayModeSchema p return CreateCompileErrorBlockedResponse(compileErrors); } - return CreateResponse("Play mode status", false, false); + return CreateResponse("Play mode status", false, false, action: parameters.Action); } private ControlPlayModeActionResult ExecuteRequestedPlayModeAction(PlayModeAction action) @@ -134,7 +135,11 @@ private ControlPlayModeActionResult CreateStatusActionResult() ControlPlayModeCompileError[] compileErrors = _compilationFailureProvider.GetLastFailedErrors() ?? Array.Empty(); - ControlPlayModeResponse response = CreateResponse("Play mode status", false, false); + ControlPlayModeResponse response = CreateResponse( + "Play mode status", + false, + false, + action: PlayModeAction.Status); response.BlockedByCompileErrors = true; response.CompileErrors = compileErrors; response.CompileErrorCount = compileErrors.Length; @@ -279,7 +284,8 @@ private ControlPlayModeResponse CreateResponse( bool changed, bool wasAlreadyStopped, bool resumedFromPause = false, - string warning = "") + string warning = "", + PlayModeAction action = PlayModeAction.Play) { ControlPlayModeResponse response = new() { @@ -292,6 +298,7 @@ private ControlPlayModeResponse CreateResponse( Message = message, Warning = warning }; + PlayModeStopReasonResponseFiller.CopyConfirmedIfNeeded(response, action, wasAlreadyStopped); return response; } diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeStopReasonResponseFiller.cs b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeStopReasonResponseFiller.cs new file mode 100644 index 0000000000..c92910cd2f --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeStopReasonResponseFiller.cs @@ -0,0 +1,41 @@ +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Copies a confirmed Play Mode stop reason onto control-play-mode responses when the spec says to. + /// + internal static class PlayModeStopReasonResponseFiller + { + internal static bool ShouldCopyConfirmedReason( + PlayModeAction action, + bool wasAlreadyStopped, + bool isPlaying) + { + if (action == PlayModeAction.Stop && wasAlreadyStopped) + { + return true; + } + + return action == PlayModeAction.Status && !isPlaying; + } + + internal static void CopyConfirmedIfNeeded( + ControlPlayModeResponse response, + PlayModeAction action, + bool wasAlreadyStopped) + { + if (!ShouldCopyConfirmedReason(action, wasAlreadyStopped, response.IsPlaying)) + { + return; + } + + PlayModeStopReasonRecord record = PlayModeStopReasonSessionStore.TryReadConfirmed(); + if (!record.HasValue) + { + return; + } + + response.StoppedBy = record.StoppedBy; + response.StoppedAt = record.StoppedAtUtc; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeStopReasonResponseFiller.cs.meta b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeStopReasonResponseFiller.cs.meta new file mode 100644 index 0000000000..f661bccccb --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeStopReasonResponseFiller.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 942e576c89a974aa5b5df5197417241e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeStopReasonSessionStore.cs b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeStopReasonSessionStore.cs new file mode 100644 index 0000000000..285aab6996 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeStopReasonSessionStore.cs @@ -0,0 +1,98 @@ +#nullable enable +using UnityEditor; +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Holds the pending Play Mode stop reason in-domain and confirms it to SessionState + /// on ExitingPlayMode so a domain reload cannot drop it. + /// + internal static class PlayModeStopReasonSessionStore + { + // Why: domain reload clears static fields; SessionState is the Editor-local store that survives it. + private const string ReasonKey = + "io.github.hatayama.uloopmcp.playModeStopReason.reason"; + private const string StoppedAtKey = + "io.github.hatayama.uloopmcp.playModeStopReason.stoppedAtUtc"; + + private static string? _pendingReason; + + internal static string? PendingReason => _pendingReason; + + internal static void SetPending(string reason) + { + Debug.Assert(!string.IsNullOrEmpty(reason), "stop reason must not be empty."); + _pendingReason = reason; + } + + // Why Try: compilationStarted is a fallback and must not replace an explicit CLI stop reason. + internal static void TrySetPending(string reason) + { + Debug.Assert(!string.IsNullOrEmpty(reason), "stop reason must not be empty."); + if (_pendingReason != null) + { + return; + } + + _pendingReason = reason; + } + + internal static void ClearPendingIfScriptCompilationFallback() + { + if (_pendingReason != ControlPlayModeConstants.StoppedByScriptCompilation) + { + return; + } + + _pendingReason = null; + } + + internal static void ConfirmPending(string stoppedAtUtc) + { + Debug.Assert(!string.IsNullOrEmpty(stoppedAtUtc), "stoppedAtUtc must not be empty."); + string reason = _pendingReason ?? ControlPlayModeConstants.StoppedByUnknown; + SessionState.SetString(ReasonKey, reason); + SessionState.SetString(StoppedAtKey, stoppedAtUtc); + _pendingReason = null; + } + + internal static PlayModeStopReasonRecord TryReadConfirmed() + { + string reason = SessionState.GetString(ReasonKey, string.Empty); + if (string.IsNullOrEmpty(reason)) + { + return PlayModeStopReasonRecord.Empty; + } + + return new PlayModeStopReasonRecord( + reason, + SessionState.GetString(StoppedAtKey, string.Empty)); + } + + internal static void ClearForTests() + { + _pendingReason = null; + SessionState.SetString(ReasonKey, string.Empty); + SessionState.SetString(StoppedAtKey, string.Empty); + } + } + + /// + /// Confirmed Play Mode stop reason copied onto control-play-mode responses. + /// + internal readonly struct PlayModeStopReasonRecord + { + internal static PlayModeStopReasonRecord Empty => new PlayModeStopReasonRecord(null, null); + + internal PlayModeStopReasonRecord(string? stoppedBy, string? stoppedAtUtc) + { + StoppedBy = stoppedBy; + StoppedAtUtc = stoppedAtUtc; + } + + internal string? StoppedBy { get; } + internal string? StoppedAtUtc { get; } + internal bool HasValue => !string.IsNullOrEmpty(StoppedBy); + } +} diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeStopReasonSessionStore.cs.meta b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeStopReasonSessionStore.cs.meta new file mode 100644 index 0000000000..72dc37bd5c --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeStopReasonSessionStore.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9b1302eb9e61948e28e52442a8345ac7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeStopReasonSubscriber.cs b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeStopReasonSubscriber.cs new file mode 100644 index 0000000000..58d649610d --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeStopReasonSubscriber.cs @@ -0,0 +1,53 @@ +using System; +using System.Globalization; +using UnityEditor; +using UnityEditor.Compilation; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Confirms the pending Play Mode stop reason when Play Mode exits, and records + /// script-compilation as a fallback when no explicit CLI stop is pending. + /// + internal static class PlayModeStopReasonSubscriber + { + // Why not EditorRuntimeStateSnapshotSubscriber: Infrastructure's asmdef allowlist cannot + // reference ControlPlayMode, so this feature-owner assembly owns the same startup hook pattern. + internal static void InitializeForEditorStartup() + { + CompilationPipeline.compilationStarted -= HandleCompilationStarted; + CompilationPipeline.compilationStarted += HandleCompilationStarted; + CompilationPipeline.compilationFinished -= HandleCompilationFinished; + CompilationPipeline.compilationFinished += HandleCompilationFinished; + EditorApplication.playModeStateChanged -= HandlePlayModeStateChanged; + EditorApplication.playModeStateChanged += HandlePlayModeStateChanged; + } + + // Why not gate compilationStarted on isPlaying: ExitingPlayMode vs compilationStarted + // order under Stop-Playing-And-Recompile is not guaranteed, so that gate could lose + // the one true script-compilation labeling case. + internal static void HandleCompilationStarted(object context) + { + PlayModeStopReasonSessionStore.TrySetPending( + ControlPlayModeConstants.StoppedByScriptCompilation); + } + + internal static void HandlePlayModeStateChanged(PlayModeStateChange state) + { + if (state != PlayModeStateChange.ExitingPlayMode) + { + return; + } + + PlayModeStopReasonSessionStore.ConfirmPending( + DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture)); + } + + // Why: a failed compile never domain-reloads, so a script-compilation fallback would + // otherwise stay pending and mislabel a later manual stop. Explicit CLI reasons must stay. + internal static void HandleCompilationFinished(object context) + { + PlayModeStopReasonSessionStore.ClearPendingIfScriptCompilationFallback(); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeStopReasonSubscriber.cs.meta b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeStopReasonSubscriber.cs.meta new file mode 100644 index 0000000000..2ee78c96ef --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeStopReasonSubscriber.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7301bad9559d44d6ea2dc47b45e44f72 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/SKILL.md index 929537ee68..6f18608780 100644 --- a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/SKILL.md @@ -32,6 +32,8 @@ Returns JSON with the current play mode state: - `ResumedFromPause`: Whether `Play` resumed a paused Play Mode session instead of starting a new one - `Message`: Description of the action performed - `Warning`: Set when the action carries a caveat. A fresh `Play` start always notes that the session started from Edit-time scene state; additionally, when active hot-reload patches or enabled pause points exist and Domain Reload is enabled, it reports how many of them the Play-entry domain reload will discard. +- `StoppedBy` (string, optional): Why Play Mode last stopped: `cli-control-play-mode`, `cli-compile-stop-setting`, `cli-run-tests-cancel`, `script-compilation`, or `unknown`. Present on `Stop` when Play Mode was already stopped, and on `Status` when Play Mode is not running. Omitted when this Editor session has no confirmed stop. +- `StoppedAt` (string, optional): UTC ISO 8601 timestamp of that stop. Omitted together with `StoppedBy` when no stop is recorded. ## Notes diff --git a/Packages/src/Editor/FirstPartyTools/RunTests/TestFramework/RunTestsCancelStopRestoreUnityHooks.cs b/Packages/src/Editor/FirstPartyTools/RunTests/TestFramework/RunTestsCancelStopRestoreUnityHooks.cs index 4cbc61f46b..28a8a7c55d 100644 --- a/Packages/src/Editor/FirstPartyTools/RunTests/TestFramework/RunTestsCancelStopRestoreUnityHooks.cs +++ b/Packages/src/Editor/FirstPartyTools/RunTests/TestFramework/RunTestsCancelStopRestoreUnityHooks.cs @@ -39,17 +39,24 @@ internal static RunTestsCancelStopRestoreHooks CreateDefault() ? TestRunnerApiCancelBridge.TryIsRunActive : null, IsPlaying = () => EditorApplication.isPlaying, - RequestExitPlayMode = () => - { - if (EditorApplication.isPlaying) - { - EditorApplication.isPlaying = false; - } - }, + RequestExitPlayMode = StopPlayingForCancel, DelayAsync = (milliseconds, ct) => TimerDelay.Wait(milliseconds, ct), LogWarning = message => Debug.LogWarning(message) }; } + + /// + /// Records the run-tests cancel stop reason, then exits Play Mode when it is running. + /// + internal static void StopPlayingForCancel() + { + PlayModeStopReasonSessionStore.SetPending( + ControlPlayModeConstants.StoppedByCliRunTestsCancel); + if (EditorApplication.isPlaying) + { + EditorApplication.isPlaying = false; + } + } } } #endif diff --git a/Packages/src/Editor/FirstPartyTools/RunTests/TestFramework/UnityCLILoop.FirstPartyTools.RunTests.TestFramework.Editor.asmdef b/Packages/src/Editor/FirstPartyTools/RunTests/TestFramework/UnityCLILoop.FirstPartyTools.RunTests.TestFramework.Editor.asmdef index 221c8dd2ad..ed96ef4587 100644 --- a/Packages/src/Editor/FirstPartyTools/RunTests/TestFramework/UnityCLILoop.FirstPartyTools.RunTests.TestFramework.Editor.asmdef +++ b/Packages/src/Editor/FirstPartyTools/RunTests/TestFramework/UnityCLILoop.FirstPartyTools.RunTests.TestFramework.Editor.asmdef @@ -3,6 +3,7 @@ "rootNamespace": "io.github.hatayama.UnityCliLoop.FirstPartyTools", "references": [ "UnityCLILoop.FirstPartyTools.RunTests.Editor", + "UnityCLILoop.FirstPartyTools.ControlPlayMode.Editor", "UnityCLILoop.FirstPartyTools.Common.EditorUtility.Editor", "UnityCLILoop.FirstPartyTools.Common.OutputRetention.Editor", "UnityCLILoop.ToolContracts",