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",