From b86d9aa85ab744f5a05d9812089e4f57098416fb Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 3 Jun 2026 14:22:34 +0900 Subject: [PATCH 01/26] Add named Unity pause point markers Introduce an editor-only pause point marker API modeled on Unity's conditional call-site pattern, plus first-party arm and clear tools backed by an internal status bridge. This replaces direct Debug.Break usage with an armed marker flow that can expire or be cleared safely. --- .../Editor/OnionAssemblyDependencyTests.cs | 18 +- Assets/Tests/Editor/PausePointTests.cs | 121 +++++++ Assets/Tests/Editor/PausePointTests.cs.meta | 11 + .../Editor/UnityCLILoop.Tests.Editor.asmdef | 2 + .../Editor/UnityCliLoopToolRegistryTests.cs | 21 ++ .../Editor/FirstPartyTools/PausePoint.meta | 8 + .../PausePoint/PausePointTools.cs | 174 ++++++++++ .../PausePoint/PausePointTools.cs.meta | 11 + ...p.FirstPartyTools.PausePoint.Editor.asmdef | 19 + ...stPartyTools.PausePoint.Editor.asmdef.meta | 7 + ...UnityCLILoop.FirstPartyTools.Editor.asmdef | 1 + .../Api/InternalBridgeCommandRouter.cs | 12 + .../Api/PausePointStatusBridgeCommand.cs | 80 +++++ .../Api/PausePointStatusBridgeCommand.cs.meta | 11 + .../UnityCLILoop.Infrastructure.asmdef | 3 +- .../ToolContracts/UnityCliLoopConstants.cs | 4 + Packages/src/Runtime/PausePoints.meta | 8 + .../src/Runtime/PausePoints/AssemblyInfo.cs | 5 + .../Runtime/PausePoints/AssemblyInfo.cs.meta | 11 + .../Runtime/PausePoints/UloopPausePoint.cs | 21 ++ .../PausePoints/UloopPausePoint.cs.meta | 11 + .../PausePoints/UloopPausePointRegistry.cs | 326 ++++++++++++++++++ .../UloopPausePointRegistry.cs.meta | 11 + .../UnityCLILoop.PausePoints.Runtime.asmdef | 14 + ...ityCLILoop.PausePoints.Runtime.asmdef.meta | 7 + 25 files changed, 910 insertions(+), 7 deletions(-) create mode 100644 Assets/Tests/Editor/PausePointTests.cs create mode 100644 Assets/Tests/Editor/PausePointTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/PausePoint.meta create mode 100644 Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs create mode 100644 Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/PausePoint/UnityCLILoop.FirstPartyTools.PausePoint.Editor.asmdef create mode 100644 Packages/src/Editor/FirstPartyTools/PausePoint/UnityCLILoop.FirstPartyTools.PausePoint.Editor.asmdef.meta create mode 100644 Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs create mode 100644 Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs.meta create mode 100644 Packages/src/Runtime/PausePoints.meta create mode 100644 Packages/src/Runtime/PausePoints/AssemblyInfo.cs create mode 100644 Packages/src/Runtime/PausePoints/AssemblyInfo.cs.meta create mode 100644 Packages/src/Runtime/PausePoints/UloopPausePoint.cs create mode 100644 Packages/src/Runtime/PausePoints/UloopPausePoint.cs.meta create mode 100644 Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs create mode 100644 Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs.meta create mode 100644 Packages/src/Runtime/PausePoints/UnityCLILoop.PausePoints.Runtime.asmdef create mode 100644 Packages/src/Runtime/PausePoints/UnityCLILoop.PausePoints.Runtime.asmdef.meta diff --git a/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs b/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs index 74cf5b2cc5..438a431a05 100644 --- a/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs +++ b/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs @@ -31,6 +31,8 @@ public sealed class OnionAssemblyDependencyTests private const string FindGameObjectsAssemblyName = "UnityCLILoop.FirstPartyTools.FindGameObjects.Editor"; private const string GetHierarchyAssemblyName = "UnityCLILoop.FirstPartyTools.GetHierarchy.Editor"; private const string GetLogsAssemblyName = "UnityCLILoop.FirstPartyTools.GetLogs.Editor"; + private const string PausePointAssemblyName = "UnityCLILoop.FirstPartyTools.PausePoint.Editor"; + private const string PausePointsRuntimeAssemblyName = "UnityCLILoop.PausePoints.Runtime"; private const string RecordInputAssemblyName = "UnityCLILoop.FirstPartyTools.RecordInput.Editor"; private const string ReplayInputAssemblyName = "UnityCLILoop.FirstPartyTools.ReplayInput.Editor"; private const string RunTestsAssemblyName = "UnityCLILoop.FirstPartyTools.RunTests.Editor"; @@ -131,16 +133,13 @@ public void ProjectAsmdefNames_WhenLoaded_UseUnityCliLoopName() } [Test] - public void ProjectAsmdefs_WhenLoaded_AutoReferenceOnlyPublicToolContracts() + public void ProjectAsmdefs_WhenLoaded_AutoReferenceOnlyPublicAssemblies() { - // Tests that internal assemblies require explicit asmdef references. + // Tests that internal assemblies require explicit asmdef references while public API assemblies stay reachable. string[] offendingAssemblyNames = ReadProjectAsmdefPaths() .Where(ReadAutoReferencedFromAbsolutePath) .Select(ReadAsmdefName) - .Where(assemblyName => !string.Equals( - assemblyName, - ToolContractsAssemblyName, - StringComparison.Ordinal)) + .Where(assemblyName => !IsPublicAutoReferencedAssembly(assemblyName)) .OrderBy(assemblyName => assemblyName) .ToArray(); @@ -595,6 +594,7 @@ public void FirstPartyToolsAsmdef_WhenLoaded_DoesNotReferenceImplementationLayer Assert.That(references, Does.Contain(FindGameObjectsAssemblyName)); Assert.That(references, Does.Contain(GetHierarchyAssemblyName)); Assert.That(references, Does.Contain(GetLogsAssemblyName)); + Assert.That(references, Does.Contain(PausePointAssemblyName)); Assert.That(references, Does.Contain(RecordInputAssemblyName)); Assert.That(references, Does.Contain(ReplayInputAssemblyName)); Assert.That(references, Does.Contain(RunTestsAssemblyName)); @@ -1262,6 +1262,12 @@ private static string[] ReadResolvedReferences(string relativeAsmdefPath) return ReadResolvedReferencesFromAbsolutePath(asmdefPath); } + private static bool IsPublicAutoReferencedAssembly(string assemblyName) + { + return string.Equals(assemblyName, ToolContractsAssemblyName, StringComparison.Ordinal) || + string.Equals(assemblyName, PausePointsRuntimeAssemblyName, StringComparison.Ordinal); + } + private static string[] ReadResolvedReferencesFromAbsolutePath(string asmdefPath) { Dictionary guidToAssemblyNameMap = BuildGuidToAssemblyNameMap(); diff --git a/Assets/Tests/Editor/PausePointTests.cs b/Assets/Tests/Editor/PausePointTests.cs new file mode 100644 index 0000000000..5cbf622a59 --- /dev/null +++ b/Assets/Tests/Editor/PausePointTests.cs @@ -0,0 +1,121 @@ +using System; +using System.IO; +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.Runtime; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies named pause point behavior without pausing the real Unity Editor during tests. + /// + [TestFixture] + public sealed class PausePointTests + { + private DateTime _nowUtc; + private FakePauseController _pauseController; + + [SetUp] + public void SetUp() + { + _nowUtc = new DateTime(2026, 6, 3, 0, 0, 0, DateTimeKind.Utc); + _pauseController = new FakePauseController(); + UloopPausePointRegistry.ConfigureForTests(_pauseController, () => _nowUtc); + } + + [TearDown] + public void TearDown() + { + UloopPausePointRegistry.ResetForTests(); + } + + [Test] + public void Hit_WhenPausePointIsNotArmed_DoesNotPause() + { + // Verifies marker calls are no-op until the CLI arms the same id. + UloopPausePoint.Hit("jump"); + + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("jump"); + + Assert.That(_pauseController.PauseCount, Is.EqualTo(0)); + Assert.That(snapshot.Status, Is.EqualTo(UloopPausePointStatus.NotArmed)); + Assert.That(snapshot.IsArmed, Is.False); + } + + [Test] + public void Hit_WhenPausePointIsArmed_RecordsHitAndRequestsPause() + { + // Verifies an armed marker hit records state and requests a Unity pause. + UloopPausePointRegistry.Arm("jump", 30); + + UloopPausePoint.Hit("jump"); + + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("jump"); + Assert.That(_pauseController.PauseCount, Is.EqualTo(1)); + Assert.That(snapshot.Status, Is.EqualTo(UloopPausePointStatus.Hit)); + Assert.That(snapshot.IsHit, Is.True); + Assert.That(snapshot.IsArmed, Is.False); + Assert.That(snapshot.IsPaused, Is.True); + Assert.That(snapshot.HitCount, Is.EqualTo(1)); + } + + [Test] + public void GetStatus_WhenTimeoutPasses_ExpiresAndDisarms() + { + // Verifies timeout disarms the marker before a late hit can pause Unity. + UloopPausePointRegistry.Arm("jump", 1); + _nowUtc = _nowUtc.AddSeconds(2); + + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("jump"); + UloopPausePoint.Hit("jump"); + + Assert.That(snapshot.Status, Is.EqualTo(UloopPausePointStatus.Expired)); + Assert.That(snapshot.IsArmed, Is.False); + Assert.That(_pauseController.PauseCount, Is.EqualTo(0)); + } + + [Test] + public void Clear_WhenPausePointIsArmed_DisarmsWithoutPause() + { + // Verifies explicit clear prevents later marker hits from pausing Unity. + UloopPausePointRegistry.Arm("jump", 30); + + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.Clear("jump"); + UloopPausePoint.Hit("jump"); + + Assert.That(snapshot.Status, Is.EqualTo(UloopPausePointStatus.Cleared)); + Assert.That(snapshot.IsArmed, Is.False); + Assert.That(_pauseController.PauseCount, Is.EqualTo(0)); + } + + [Test] + public void HitMethod_WhenSourceIsScanned_UsesUnityEditorConditionalWithoutDebugBreak() + { + // Verifies the public marker follows Unity's conditional call-site removal pattern. + string sourcePath = Path.Combine( + Directory.GetCurrentDirectory(), + "Packages/src/Runtime/PausePoints/UloopPausePoint.cs"); + string source = File.ReadAllText(sourcePath); + + Assert.That(source, Does.Contain("[Conditional(\"UNITY_EDITOR\")]")); + Assert.That(source, Does.Contain("public static void Hit(string id)")); + Assert.That(source, Does.Not.Contain("Debug.Break")); + } + + /// + /// Test double that records pause requests without mutating Unity Editor state. + /// + private sealed class FakePauseController : IUloopPausePointPauseController + { + public bool IsPlaying { get; private set; } = true; + public bool IsPaused { get; private set; } + public int PauseCount { get; private set; } + + public void Pause() + { + PauseCount++; + IsPaused = true; + } + } + } +} diff --git a/Assets/Tests/Editor/PausePointTests.cs.meta b/Assets/Tests/Editor/PausePointTests.cs.meta new file mode 100644 index 0000000000..b67375f015 --- /dev/null +++ b/Assets/Tests/Editor/PausePointTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6ef9b4ed66664a08857f24346bc8b174 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/UnityCLILoop.Tests.Editor.asmdef b/Assets/Tests/Editor/UnityCLILoop.Tests.Editor.asmdef index f8562f30b4..c6a49eefbc 100644 --- a/Assets/Tests/Editor/UnityCLILoop.Tests.Editor.asmdef +++ b/Assets/Tests/Editor/UnityCLILoop.Tests.Editor.asmdef @@ -15,6 +15,8 @@ "GUID:f1a38fcd7e85047c5a86331a3e37ddfc", "GUID:45d3617c96fa64d3e95383450e67f25a", "GUID:a2d87883023de4cf59b7d1962d5dd8aa", + "GUID:527f26a36b5043c2bd4d4036d04cd76d", + "GUID:94d8abc693f543a691a4645a5ff42e5c", "GUID:aa7cf56cc5f074e57ba35272b877e116", "GUID:a39242918a7ab45519b2c80f798dce33", "GUID:0cf5a90ce140e4729abd701bea48b00e", diff --git a/Assets/Tests/Editor/UnityCliLoopToolRegistryTests.cs b/Assets/Tests/Editor/UnityCliLoopToolRegistryTests.cs index b273491233..a274e7460e 100644 --- a/Assets/Tests/Editor/UnityCliLoopToolRegistryTests.cs +++ b/Assets/Tests/Editor/UnityCliLoopToolRegistryTests.cs @@ -37,6 +37,7 @@ public sealed class UnityCliLoopToolRegistryTests private const string FindGameObjectsAssemblyName = "UnityCLILoop.FirstPartyTools.FindGameObjects.Editor"; private const string GetHierarchyAssemblyName = "UnityCLILoop.FirstPartyTools.GetHierarchy.Editor"; private const string GetLogsAssemblyName = "UnityCLILoop.FirstPartyTools.GetLogs.Editor"; + private const string PausePointAssemblyName = "UnityCLILoop.FirstPartyTools.PausePoint.Editor"; private const string RecordInputAssemblyName = "UnityCLILoop.FirstPartyTools.RecordInput.Editor"; private const string ReplayInputAssemblyName = "UnityCLILoop.FirstPartyTools.ReplayInput.Editor"; private const string RunTestsAssemblyName = "UnityCLILoop.FirstPartyTools.RunTests.Editor"; @@ -53,6 +54,8 @@ public void Constructor_WhenFirstPartyToolsUseToolAttribute_RegistersThem() Assert.That(registry.IsToolRegistered("compile"), Is.True); Assert.That(registry.IsToolRegistered("get-logs"), Is.True); + Assert.That(registry.IsToolRegistered(UnityCliLoopConstants.TOOL_NAME_ARM_PAUSE_POINT), Is.True); + Assert.That(registry.IsToolRegistered(UnityCliLoopConstants.TOOL_NAME_CLEAR_PAUSE_POINT), Is.True); Assert.That(registry.IsToolRegistered("execute-dynamic-code"), Is.True); Assert.That(registry.IsToolRegistered("clear-console"), Is.True); Assert.That(registry.IsToolRegistered("get-hierarchy"), Is.True); @@ -248,6 +251,23 @@ public void GetToolType_WhenSimulateMouseUiComesFromFirstPartyToolsAssembly_Retu AssertThirdPartyTool(toolType, false); } + [Test] + public void GetToolType_WhenPausePointComesFromFirstPartyToolsAssembly_ReturnsBundledPluginType() + { + // Tests that pause point tools are bundled plugins instead of application-layer tools. + UnityCliLoopToolRegistry registry = ToolRegistryTestFactory.Create(); + + System.Type armToolType = registry.GetToolType(UnityCliLoopConstants.TOOL_NAME_ARM_PAUSE_POINT); + System.Type clearToolType = registry.GetToolType(UnityCliLoopConstants.TOOL_NAME_CLEAR_PAUSE_POINT); + + Assert.That(armToolType, Is.Not.Null); + Assert.That(clearToolType, Is.Not.Null); + Assert.That(armToolType.Assembly.GetName().Name, Is.EqualTo(PausePointAssemblyName)); + Assert.That(clearToolType.Assembly.GetName().Name, Is.EqualTo(PausePointAssemblyName)); + AssertThirdPartyTool(armToolType, false); + AssertThirdPartyTool(clearToolType, false); + } + [Test] public void Constructor_WhenFocusWindowIsNativeCliCommand_DoesNotRegisterItAsTool() { @@ -497,6 +517,7 @@ public void FirstPartyToolsAsmdef_DoesNotReferenceImplementationLayers() Assert.That(references, Does.Contain(FindGameObjectsAssemblyName)); Assert.That(references, Does.Contain(GetHierarchyAssemblyName)); Assert.That(references, Does.Contain(GetLogsAssemblyName)); + Assert.That(references, Does.Contain(PausePointAssemblyName)); Assert.That(references, Does.Contain(RecordInputAssemblyName)); Assert.That(references, Does.Contain(ReplayInputAssemblyName)); Assert.That(references, Does.Contain(RunTestsAssemblyName)); diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint.meta b/Packages/src/Editor/FirstPartyTools/PausePoint.meta new file mode 100644 index 0000000000..466d1ee8c2 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/PausePoint.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 15bef1671be348ba88df00a8f77f17e6 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs new file mode 100644 index 0000000000..f52aa74fe6 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs @@ -0,0 +1,174 @@ +using System; +using System.ComponentModel; +using System.Threading; +using System.Threading.Tasks; + +using io.github.hatayama.UnityCliLoop.Runtime; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Parameters for arming one named pause point marker. + /// + public class ArmPausePointSchema : UnityCliLoopToolSchema + { + [Description("Named pause point id passed to UloopPausePoint.Hit.")] + public string Id { get; set; } = string.Empty; + + [Description("Seconds before the arm expires and stops pausing late hits.")] + public int TimeoutSeconds { get; set; } = UloopPausePointRegistry.DefaultTimeoutSeconds; + } + + /// + /// Parameters for clearing one or all pause point markers. + /// + public class ClearPausePointSchema : UnityCliLoopToolSchema + { + [Description("Named pause point id to clear.")] + public string Id { get; set; } = string.Empty; + + [Description("Clear every active pause point marker.")] + public bool All { get; set; } + } + + /// + /// Response shared by pause point tool commands. + /// + public class PausePointResponse : UnityCliLoopToolResponse + { + public string Id { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public bool IsArmed { get; set; } + public bool IsHit { get; set; } + public int HitCount { get; set; } + public int TimeoutSeconds { get; set; } + public long ElapsedMilliseconds { get; set; } + public bool IsPlaying { get; set; } + public bool IsPaused { get; set; } + public int ClearedCount { get; set; } + public string Message { get; set; } = string.Empty; + + internal static PausePointResponse FromSnapshot(UloopPausePointSnapshot snapshot) + { + if (snapshot == null) + { + throw new ArgumentNullException(nameof(snapshot)); + } + + return new PausePointResponse + { + Id = snapshot.Id, + Status = snapshot.Status, + IsArmed = snapshot.IsArmed, + IsHit = snapshot.IsHit, + HitCount = snapshot.HitCount, + TimeoutSeconds = snapshot.TimeoutSeconds, + ElapsedMilliseconds = snapshot.ElapsedMilliseconds, + IsPlaying = snapshot.IsPlaying, + IsPaused = snapshot.IsPaused, + Message = snapshot.Message + }; + } + + internal static PausePointResponse FromClearAll(UloopPausePointClearAllResult result) + { + if (result == null) + { + throw new ArgumentNullException(nameof(result)); + } + + return new PausePointResponse + { + Status = UloopPausePointStatus.Cleared, + ClearedCount = result.ClearedCount, + Message = "Pause points cleared." + }; + } + } + + /// + /// Exposes pause point arming as a Unity CLI Loop tool. + /// + [UnityCliLoopTool] + public class ArmPausePointTool : UnityCliLoopTool + { + public override string ToolName => UnityCliLoopConstants.TOOL_NAME_ARM_PAUSE_POINT; + + protected override Task ExecuteAsync(ArmPausePointSchema parameters, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + PausePointUseCase useCase = new(); + PausePointResponse response = useCase.Arm(parameters); + return Task.FromResult(response); + } + } + + /// + /// Exposes pause point clearing as a Unity CLI Loop tool. + /// + [UnityCliLoopTool] + public class ClearPausePointTool : UnityCliLoopTool + { + public override string ToolName => UnityCliLoopConstants.TOOL_NAME_CLEAR_PAUSE_POINT; + + protected override Task ExecuteAsync(ClearPausePointSchema parameters, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + PausePointUseCase useCase = new(); + PausePointResponse response = useCase.Clear(parameters); + return Task.FromResult(response); + } + } + + /// + /// Coordinates pause point tool validation and registry updates. + /// + internal sealed class PausePointUseCase + { + public PausePointResponse Arm(ArmPausePointSchema parameters) + { + if (parameters == null) + { + throw new ArgumentNullException(nameof(parameters)); + } + + string id = RequireId(parameters.Id); + if (parameters.TimeoutSeconds <= 0) + { + throw new UnityCliLoopToolParameterValidationException("TimeoutSeconds must be greater than zero."); + } + + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.Arm(id, parameters.TimeoutSeconds); + return PausePointResponse.FromSnapshot(snapshot); + } + + public PausePointResponse Clear(ClearPausePointSchema parameters) + { + if (parameters == null) + { + throw new ArgumentNullException(nameof(parameters)); + } + + if (parameters.All) + { + UloopPausePointClearAllResult clearAllResult = UloopPausePointRegistry.ClearAll(); + return PausePointResponse.FromClearAll(clearAllResult); + } + + string id = RequireId(parameters.Id); + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.Clear(id); + return PausePointResponse.FromSnapshot(snapshot); + } + + private static string RequireId(string id) + { + if (string.IsNullOrWhiteSpace(id)) + { + throw new UnityCliLoopToolParameterValidationException("Id must not be null or empty."); + } + + return id; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs.meta b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs.meta new file mode 100644 index 0000000000..36822b3edc --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7420cff4bb8f43b9bbf7857c811e3540 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/UnityCLILoop.FirstPartyTools.PausePoint.Editor.asmdef b/Packages/src/Editor/FirstPartyTools/PausePoint/UnityCLILoop.FirstPartyTools.PausePoint.Editor.asmdef new file mode 100644 index 0000000000..e40c785b96 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/UnityCLILoop.FirstPartyTools.PausePoint.Editor.asmdef @@ -0,0 +1,19 @@ +{ + "name": "UnityCLILoop.FirstPartyTools.PausePoint.Editor", + "rootNamespace": "io.github.hatayama.UnityCliLoop.FirstPartyTools", + "references": [ + "GUID:fc3fd32eddbee40e39c2d76dc184957b", + "GUID:527f26a36b5043c2bd4d4036d04cd76d" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": false, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/UnityCLILoop.FirstPartyTools.PausePoint.Editor.asmdef.meta b/Packages/src/Editor/FirstPartyTools/PausePoint/UnityCLILoop.FirstPartyTools.PausePoint.Editor.asmdef.meta new file mode 100644 index 0000000000..4078b2fa67 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/UnityCLILoop.FirstPartyTools.PausePoint.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 94d8abc693f543a691a4645a5ff42e5c +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/UnityCLILoop.FirstPartyTools.Editor.asmdef b/Packages/src/Editor/FirstPartyTools/UnityCLILoop.FirstPartyTools.Editor.asmdef index d41404befe..5b96c037c7 100644 --- a/Packages/src/Editor/FirstPartyTools/UnityCLILoop.FirstPartyTools.Editor.asmdef +++ b/Packages/src/Editor/FirstPartyTools/UnityCLILoop.FirstPartyTools.Editor.asmdef @@ -9,6 +9,7 @@ "GUID:ee36dcf19655f43c08e0a7009ca7a624", "GUID:1b127da462d854085a0384d517187480", "GUID:54dfc5fd447794df2aa683025462d668", + "GUID:94d8abc693f543a691a4645a5ff42e5c", "GUID:dc412b2cd951d452b8dcec99c3adeb19", "GUID:3b7aa330d87dc452293296dd5830859b", "GUID:9ed21973545c44e28af522b049dec47d", diff --git a/Packages/src/Editor/Infrastructure/Api/InternalBridgeCommandRouter.cs b/Packages/src/Editor/Infrastructure/Api/InternalBridgeCommandRouter.cs index d3c9d8ccec..0c2f0383f3 100644 --- a/Packages/src/Editor/Infrastructure/Api/InternalBridgeCommandRouter.cs +++ b/Packages/src/Editor/Infrastructure/Api/InternalBridgeCommandRouter.cs @@ -15,6 +15,8 @@ public static bool IsInternalCommand(string commandName) { return commandName == UnityCliLoopConstants.COMMAND_NAME_GET_VERSION || commandName == UnityCliLoopConstants.COMMAND_NAME_GET_COMPILE_STATUS || + commandName == UnityCliLoopConstants.COMMAND_NAME_GET_PAUSE_POINT_STATUS || + commandName == UnityCliLoopConstants.COMMAND_NAME_CLEAR_PAUSE_POINT_STATUS || commandName == UnityCliLoopConstants.COMMAND_NAME_GET_TOOL_DETAILS; } @@ -37,6 +39,16 @@ public static UnityCliLoopToolResponse Execute(string commandName, JToken params return CompileStatusBridgeCommand.Execute(paramsToken); } + if (commandName == UnityCliLoopConstants.COMMAND_NAME_GET_PAUSE_POINT_STATUS) + { + return PausePointStatusBridgeCommand.Execute(paramsToken); + } + + if (commandName == UnityCliLoopConstants.COMMAND_NAME_CLEAR_PAUSE_POINT_STATUS) + { + return PausePointStatusBridgeCommand.Clear(paramsToken); + } + throw new ArgumentException($"Unknown internal bridge command: {commandName}", nameof(commandName)); } } diff --git a/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs b/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs new file mode 100644 index 0000000000..32948ed74a --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs @@ -0,0 +1,80 @@ +using System; +using Newtonsoft.Json.Linq; + +using io.github.hatayama.UnityCliLoop.Runtime; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// Serves CLI-only pause point status and cleanup requests outside the normal tool slot. + /// + internal static class PausePointStatusBridgeCommand + { + private const string IdParamName = "Id"; + + public static PausePointStatusResponse Execute(JToken paramsToken) + { + string id = ReadId(paramsToken); + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus(id); + return PausePointStatusResponse.FromSnapshot(snapshot); + } + + public static PausePointStatusResponse Clear(JToken paramsToken) + { + string id = ReadId(paramsToken); + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.Clear(id); + return PausePointStatusResponse.FromSnapshot(snapshot); + } + + private static string ReadId(JToken paramsToken) + { + if (paramsToken is not JObject paramsObject) + { + return string.Empty; + } + + JToken idToken = paramsObject.GetValue(IdParamName, StringComparison.OrdinalIgnoreCase); + return idToken?.ToString() ?? string.Empty; + } + } + + /// + /// Pause point status payload returned by the internal CLI polling bridge command. + /// + public class PausePointStatusResponse : UnityCliLoopToolResponse + { + public string Id { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public bool IsArmed { get; set; } + public bool IsHit { get; set; } + public int HitCount { get; set; } + public int TimeoutSeconds { get; set; } + public long ElapsedMilliseconds { get; set; } + public bool IsPlaying { get; set; } + public bool IsPaused { get; set; } + public string Message { get; set; } = string.Empty; + + internal static PausePointStatusResponse FromSnapshot(UloopPausePointSnapshot snapshot) + { + if (snapshot == null) + { + throw new ArgumentNullException(nameof(snapshot)); + } + + return new PausePointStatusResponse + { + Id = snapshot.Id, + Status = snapshot.Status, + IsArmed = snapshot.IsArmed, + IsHit = snapshot.IsHit, + HitCount = snapshot.HitCount, + TimeoutSeconds = snapshot.TimeoutSeconds, + ElapsedMilliseconds = snapshot.ElapsedMilliseconds, + IsPlaying = snapshot.IsPlaying, + IsPaused = snapshot.IsPaused, + Message = snapshot.Message + }; + } + } +} diff --git a/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs.meta b/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs.meta new file mode 100644 index 0000000000..44bd41b9f0 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bc8ad5e99ba7447b80713de893faab9a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/UnityCLILoop.Infrastructure.asmdef b/Packages/src/Editor/Infrastructure/UnityCLILoop.Infrastructure.asmdef index 191f71e2f8..7da9fee33e 100644 --- a/Packages/src/Editor/Infrastructure/UnityCLILoop.Infrastructure.asmdef +++ b/Packages/src/Editor/Infrastructure/UnityCLILoop.Infrastructure.asmdef @@ -5,7 +5,8 @@ "GUID:214998e563c124e8a88199b2dd1f522d", "GUID:5c4588558a3624eacbce0f50007cf1eb", "GUID:fc3fd32eddbee40e39c2d76dc184957b", - "GUID:5079a8d3a72924a81aa1cbc25f65ed1b" + "GUID:5079a8d3a72924a81aa1cbc25f65ed1b", + "GUID:527f26a36b5043c2bd4d4036d04cd76d" ], "includePlatforms": [ "Editor" diff --git a/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs b/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs index d92ca6a235..5f9a92d075 100644 --- a/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs +++ b/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs @@ -61,6 +61,8 @@ public static UnityEditor.PackageManager.PackageInfo PackageInfo // Command name constants public const string TOOL_NAME_COMPILE = "compile"; public const string TOOL_NAME_CONTROL_PLAY_MODE = "control-play-mode"; + public const string TOOL_NAME_ARM_PAUSE_POINT = "arm-pause-point"; + public const string TOOL_NAME_CLEAR_PAUSE_POINT = "clear-pause-point"; public const string TOOL_NAME_EXECUTE_DYNAMIC_CODE = "execute-dynamic-code"; public const string TOOL_NAME_GET_HIERARCHY = "get-hierarchy"; public const string TOOL_NAME_GET_LOGS = "get-logs"; @@ -68,6 +70,8 @@ public static UnityEditor.PackageManager.PackageInfo PackageInfo public const string COMMAND_NAME_GET_TOOL_DETAILS = "get-tool-details"; public const string COMMAND_NAME_GET_VERSION = "get-version"; public const string COMMAND_NAME_GET_COMPILE_STATUS = "get-compile-status"; + public const string COMMAND_NAME_GET_PAUSE_POINT_STATUS = "get-pause-point-status"; + public const string COMMAND_NAME_CLEAR_PAUSE_POINT_STATUS = "clear-pause-point-status"; // Optional package names public const string PACKAGE_NAME_TEST_FRAMEWORK = "com.unity.test-framework"; diff --git a/Packages/src/Runtime/PausePoints.meta b/Packages/src/Runtime/PausePoints.meta new file mode 100644 index 0000000000..8884cb5f33 --- /dev/null +++ b/Packages/src/Runtime/PausePoints.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ffc175d8085b462e953bf596f441c344 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Runtime/PausePoints/AssemblyInfo.cs b/Packages/src/Runtime/PausePoints/AssemblyInfo.cs new file mode 100644 index 0000000000..5e88fa82ec --- /dev/null +++ b/Packages/src/Runtime/PausePoints/AssemblyInfo.cs @@ -0,0 +1,5 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.PausePoint.Editor")] +[assembly: InternalsVisibleTo("UnityCLILoop.Infrastructure")] +[assembly: InternalsVisibleTo("UnityCLILoop.Tests.Editor")] diff --git a/Packages/src/Runtime/PausePoints/AssemblyInfo.cs.meta b/Packages/src/Runtime/PausePoints/AssemblyInfo.cs.meta new file mode 100644 index 0000000000..de25c4e775 --- /dev/null +++ b/Packages/src/Runtime/PausePoints/AssemblyInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5d53a34cf2974d2d9657844299a17702 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Runtime/PausePoints/UloopPausePoint.cs b/Packages/src/Runtime/PausePoints/UloopPausePoint.cs new file mode 100644 index 0000000000..2ece9a7a2c --- /dev/null +++ b/Packages/src/Runtime/PausePoints/UloopPausePoint.cs @@ -0,0 +1,21 @@ +using System.Diagnostics; + +namespace io.github.hatayama.UnityCliLoop.Runtime +{ + /// + /// Marks source locations that the Unity CLI can arm as temporary Editor pause points. + /// + public static class UloopPausePoint + { + /// + /// Records a named pause point hit when the Editor has armed the same id. + /// + [Conditional("UNITY_EDITOR")] + public static void Hit(string id) + { +#if UNITY_EDITOR + UloopPausePointRegistry.Hit(id); +#endif + } + } +} diff --git a/Packages/src/Runtime/PausePoints/UloopPausePoint.cs.meta b/Packages/src/Runtime/PausePoints/UloopPausePoint.cs.meta new file mode 100644 index 0000000000..436da15cd4 --- /dev/null +++ b/Packages/src/Runtime/PausePoints/UloopPausePoint.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 67fae0568edb47b1a0644686db4874aa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs new file mode 100644 index 0000000000..a3adf224d5 --- /dev/null +++ b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs @@ -0,0 +1,326 @@ +#if UNITY_EDITOR +using System; +using System.Collections.Generic; +using UnityEditor; +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.Runtime +{ + /// + /// Stores armed pause point state for the current Editor domain. + /// + internal static class UloopPausePointRegistry + { + public const int DefaultTimeoutSeconds = 30; + + private static readonly Dictionary Entries = new(); + private static IUloopPausePointPauseController _pauseController = new UnityEditorPausePointPauseController(); + private static Func _nowProvider = () => DateTime.UtcNow; + + public static UloopPausePointSnapshot Arm(string id, int timeoutSeconds) + { + Debug.Assert(!string.IsNullOrWhiteSpace(id), "id must not be null or empty"); + Debug.Assert(timeoutSeconds > 0, "timeoutSeconds must be greater than zero"); + + DateTime now = NowUtc(); + UloopPausePointEntry entry = new(id, timeoutSeconds, now); + Entries[id] = entry; + return entry.ToSnapshot(now, _pauseController); + } + + public static UloopPausePointSnapshot Clear(string id) + { + Debug.Assert(!string.IsNullOrWhiteSpace(id), "id must not be null or empty"); + + DateTime now = NowUtc(); + if (!Entries.ContainsKey(id)) + { + return UloopPausePointSnapshot.NotArmed(id, _pauseController); + } + + UloopPausePointEntry entry = Entries[id]; + entry.MarkCleared(); + return entry.ToSnapshot(now, _pauseController); + } + + public static UloopPausePointClearAllResult ClearAll() + { + DateTime now = NowUtc(); + int clearedCount = 0; + foreach (UloopPausePointEntry entry in Entries.Values) + { + if (!entry.IsTerminal) + { + entry.MarkCleared(); + clearedCount++; + } + } + + return new UloopPausePointClearAllResult(clearedCount, now); + } + + public static UloopPausePointSnapshot GetStatus(string id) + { + Debug.Assert(!string.IsNullOrWhiteSpace(id), "id must not be null or empty"); + + DateTime now = NowUtc(); + if (!Entries.ContainsKey(id)) + { + return UloopPausePointSnapshot.NotArmed(id, _pauseController); + } + + UloopPausePointEntry entry = Entries[id]; + entry.ExpireIfNeeded(now); + return entry.ToSnapshot(now, _pauseController); + } + + public static UloopPausePointSnapshot Hit(string id) + { + if (string.IsNullOrWhiteSpace(id)) + { + Debug.Assert(false, "id must not be null or empty"); + return UloopPausePointSnapshot.NotArmed(id ?? string.Empty, _pauseController); + } + + DateTime now = NowUtc(); + if (!Entries.ContainsKey(id)) + { + return UloopPausePointSnapshot.NotArmed(id, _pauseController); + } + + UloopPausePointEntry entry = Entries[id]; + entry.ExpireIfNeeded(now); + if (!entry.IsArmed) + { + return entry.ToSnapshot(now, _pauseController); + } + + _pauseController.Pause(); + entry.RecordHit(now, _pauseController.IsPlaying, _pauseController.IsPaused); + return entry.ToSnapshot(now, _pauseController); + } + + public static void ConfigureForTests(IUloopPausePointPauseController pauseController, Func nowProvider) + { + Debug.Assert(pauseController != null, "pauseController must not be null"); + Debug.Assert(nowProvider != null, "nowProvider must not be null"); + + _pauseController = pauseController; + _nowProvider = nowProvider; + } + + public static void ResetForTests() + { + Entries.Clear(); + _pauseController = new UnityEditorPausePointPauseController(); + _nowProvider = () => DateTime.UtcNow; + } + + private static DateTime NowUtc() + { + DateTime now = _nowProvider(); + return now.Kind == DateTimeKind.Utc ? now : now.ToUniversalTime(); + } + } + + /// + /// Provides the current pause state and performs the actual Unity Editor pause request. + /// + internal interface IUloopPausePointPauseController + { + bool IsPlaying { get; } + bool IsPaused { get; } + void Pause(); + } + + /// + /// Adapts pause point hits to UnityEditor.EditorApplication state. + /// + internal sealed class UnityEditorPausePointPauseController : IUloopPausePointPauseController + { + public bool IsPlaying => EditorApplication.isPlaying; + public bool IsPaused => EditorApplication.isPaused; + + public void Pause() + { + EditorApplication.isPaused = true; + } + } + + /// + /// Immutable view of one pause point state returned to tools and the CLI bridge. + /// + internal sealed class UloopPausePointSnapshot + { + public UloopPausePointSnapshot( + string id, + string status, + bool isArmed, + bool isHit, + int hitCount, + int timeoutSeconds, + long elapsedMilliseconds, + bool isPlaying, + bool isPaused, + string message) + { + Id = id ?? string.Empty; + Status = status ?? UloopPausePointStatus.NotArmed; + IsArmed = isArmed; + IsHit = isHit; + HitCount = hitCount; + TimeoutSeconds = timeoutSeconds; + ElapsedMilliseconds = elapsedMilliseconds; + IsPlaying = isPlaying; + IsPaused = isPaused; + Message = message ?? string.Empty; + } + + public string Id { get; } + public string Status { get; } + public bool IsArmed { get; } + public bool IsHit { get; } + public int HitCount { get; } + public int TimeoutSeconds { get; } + public long ElapsedMilliseconds { get; } + public bool IsPlaying { get; } + public bool IsPaused { get; } + public string Message { get; } + + public static UloopPausePointSnapshot NotArmed(string id, IUloopPausePointPauseController pauseController) + { + Debug.Assert(pauseController != null, "pauseController must not be null"); + + return new UloopPausePointSnapshot( + id, + UloopPausePointStatus.NotArmed, + false, + false, + 0, + 0, + 0, + pauseController.IsPlaying, + pauseController.IsPaused, + "Pause point is not armed."); + } + } + + /// + /// Reports the number of pause point entries cleared by a bulk clear request. + /// + internal sealed class UloopPausePointClearAllResult + { + public UloopPausePointClearAllResult(int clearedCount, DateTime clearedAtUtc) + { + ClearedCount = clearedCount; + ClearedAtUtc = clearedAtUtc; + } + + public int ClearedCount { get; } + public DateTime ClearedAtUtc { get; } + } + + /// + /// Owns mutable state for one armed pause point id. + /// + internal sealed class UloopPausePointEntry + { + public UloopPausePointEntry(string id, int timeoutSeconds, DateTime armedAtUtc) + { + Id = id; + TimeoutSeconds = timeoutSeconds; + ArmedAtUtc = armedAtUtc; + ExpiresAtUtc = armedAtUtc.AddSeconds(timeoutSeconds); + Status = UloopPausePointStatus.Armed; + IsArmed = true; + Message = "Pause point armed."; + } + + public string Id { get; } + public int TimeoutSeconds { get; } + public DateTime ArmedAtUtc { get; } + public DateTime ExpiresAtUtc { get; } + public string Status { get; private set; } + public bool IsArmed { get; private set; } + public int HitCount { get; private set; } + public DateTime HitAtUtc { get; private set; } + public bool IsPlayingAtHit { get; private set; } + public bool IsPausedAtHit { get; private set; } + public string Message { get; private set; } + + public bool IsTerminal => + Status == UloopPausePointStatus.Hit || + Status == UloopPausePointStatus.Expired || + Status == UloopPausePointStatus.Cleared; + + public void ExpireIfNeeded(DateTime nowUtc) + { + if (!IsArmed) + { + return; + } + + if (nowUtc < ExpiresAtUtc) + { + return; + } + + IsArmed = false; + Status = UloopPausePointStatus.Expired; + Message = "Pause point expired before it was hit."; + } + + public void MarkCleared() + { + IsArmed = false; + Status = UloopPausePointStatus.Cleared; + Message = "Pause point cleared."; + } + + public void RecordHit(DateTime nowUtc, bool isPlaying, bool isPaused) + { + HitCount++; + HitAtUtc = nowUtc; + IsPlayingAtHit = isPlaying; + IsPausedAtHit = isPaused; + IsArmed = false; + Status = UloopPausePointStatus.Hit; + Message = "Pause point hit; Unity pause was requested."; + } + + public UloopPausePointSnapshot ToSnapshot(DateTime nowUtc, IUloopPausePointPauseController pauseController) + { + Debug.Assert(pauseController != null, "pauseController must not be null"); + + bool isHit = Status == UloopPausePointStatus.Hit; + bool isPlaying = isHit ? IsPlayingAtHit : pauseController.IsPlaying; + bool isPaused = isHit ? IsPausedAtHit : pauseController.IsPaused; + long elapsedMilliseconds = Math.Max(0, (long)(nowUtc - ArmedAtUtc).TotalMilliseconds); + + return new UloopPausePointSnapshot( + Id, + Status, + IsArmed, + isHit, + HitCount, + TimeoutSeconds, + elapsedMilliseconds, + isPlaying, + isPaused, + Message); + } + } + + /// + /// Centralizes status names shared by Editor tools and the native CLI. + /// + internal static class UloopPausePointStatus + { + public const string NotArmed = "NotArmed"; + public const string Armed = "Armed"; + public const string Hit = "Hit"; + public const string Expired = "Expired"; + public const string Cleared = "Cleared"; + } +} +#endif diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs.meta b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs.meta new file mode 100644 index 0000000000..83cb402ca2 --- /dev/null +++ b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9f744d2d95214a71ab1dc79ac5a06a32 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Runtime/PausePoints/UnityCLILoop.PausePoints.Runtime.asmdef b/Packages/src/Runtime/PausePoints/UnityCLILoop.PausePoints.Runtime.asmdef new file mode 100644 index 0000000000..4089c94fc9 --- /dev/null +++ b/Packages/src/Runtime/PausePoints/UnityCLILoop.PausePoints.Runtime.asmdef @@ -0,0 +1,14 @@ +{ + "name": "UnityCLILoop.PausePoints.Runtime", + "rootNamespace": "io.github.hatayama.UnityCliLoop.Runtime", + "references": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Packages/src/Runtime/PausePoints/UnityCLILoop.PausePoints.Runtime.asmdef.meta b/Packages/src/Runtime/PausePoints/UnityCLILoop.PausePoints.Runtime.asmdef.meta new file mode 100644 index 0000000000..ae343d4923 --- /dev/null +++ b/Packages/src/Runtime/PausePoints/UnityCLILoop.PausePoints.Runtime.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 527f26a36b5043c2bd4d4036d04cd76d +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: From f833be554ec529068a75af2ce5ef2b913672131e Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 3 Jun 2026 14:22:51 +0900 Subject: [PATCH 02/26] Add pause point wait CLI workflow Add the native wait-for-pause-point command so agents can poll marker hits without occupying the normal Unity tool slot. Document the arm/wait/inspect flow and point input simulation skills at pause points for ambiguous gameplay verification. --- .../src/Editor/CliOnlyTools~/PausePoint.meta | 8 + .../CliOnlyTools~/PausePoint/Skill.meta | 8 + .../CliOnlyTools~/PausePoint/Skill/SKILL.md | 51 +++ .../PausePoint/Skill/SKILL.md.meta | 7 + .../SimulateKeyboard/Skill/SKILL.md | 3 +- .../SimulateMouseInput/Skill/SKILL.md | 3 +- cli/internal/cli/command_help.go | 2 +- cli/internal/cli/command_registry.go | 1 + cli/internal/cli/completion.go | 14 - cli/internal/cli/completion_options.go | 20 + cli/internal/cli/error_envelope.go | 4 + cli/internal/cli/error_envelope_test.go | 2 +- cli/internal/cli/pause_point_wait.go | 364 ++++++++++++++++++ cli/internal/cli/pause_point_wait_test.go | 150 ++++++++ cli/internal/cli/run.go | 2 + cli/internal/tools/default-tools.json | 35 ++ 16 files changed, 656 insertions(+), 18 deletions(-) create mode 100644 Packages/src/Editor/CliOnlyTools~/PausePoint.meta create mode 100644 Packages/src/Editor/CliOnlyTools~/PausePoint/Skill.meta create mode 100644 Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md create mode 100644 Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md.meta create mode 100644 cli/internal/cli/completion_options.go create mode 100644 cli/internal/cli/pause_point_wait.go create mode 100644 cli/internal/cli/pause_point_wait_test.go diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint.meta b/Packages/src/Editor/CliOnlyTools~/PausePoint.meta new file mode 100644 index 0000000000..535a784254 --- /dev/null +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f0336ed66c5e45a39afa8c0e11f94dd0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill.meta b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill.meta new file mode 100644 index 0000000000..8953a5ced7 --- /dev/null +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 842781e920d14996b5f2cafff69f80db +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md new file mode 100644 index 0000000000..467d7e607e --- /dev/null +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -0,0 +1,51 @@ +--- +name: uloop-wait-for-pause-point +description: "Pause Unity at a named UloopPausePoint.Hit marker when input or gameplay state is hard to verify." +--- + +# uloop wait-for-pause-point + +Pause Unity when execution reaches a named marker in user code. + +## When to use + +- Use when logs or screenshots cannot prove that a gameplay path was reached. +- Use during development debugging, not only E2E tests. +- Use after simulated keyboard or mouse input when you need to inspect exact runtime state. + +## Workflow + +1. Add a marker at the state you want to inspect: + +```csharp +using io.github.hatayama.UnityCliLoop.Runtime; + +UloopPausePoint.Hit("player-jumped"); +``` + +2. Compile the project. +3. Arm the marker before triggering the code path: + +```bash +uloop arm-pause-point --id player-jumped --timeout-seconds 30 +``` + +4. Trigger the behavior, such as `simulate-keyboard`, `simulate-mouse-input`, UI interaction, or dynamic code. +5. Wait for the marker: + +```bash +uloop wait-for-pause-point --id player-jumped --timeout-seconds 30 +``` + +6. While Unity is paused, inspect state with `uloop get-logs`, `uloop get-hierarchy`, `uloop find-game-objects`, screenshots, or `uloop execute-dynamic-code`. +7. Clear the marker if you stop waiting: + +```bash +uloop clear-pause-point --id player-jumped +``` + +## Safety + +- `UloopPausePoint.Hit` uses Unity's conditional-call pattern and is compiled out of non-Editor call sites. +- Do not pass side-effect expressions as the id argument. Use stable string ids. +- This does not collect logs or state snapshots. Use existing inspection commands after Unity pauses. diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md.meta b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md.meta new file mode 100644 index 0000000000..2a97ed5bf3 --- /dev/null +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 6cb0f0310dd648ac86977590bcfa405f +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md index debad2993c..5f0d32c21b 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md @@ -14,7 +14,8 @@ Simulate keyboard input on Unity PlayMode: $ARGUMENTS 1. Ensure Unity is in PlayMode (use `uloop control-play-mode --action Play` if not) 2. Execute the appropriate `uloop simulate-keyboard` command 3. Take a screenshot to verify the result: `uloop screenshot --capture-mode rendering` -4. Report what happened +4. If the screenshot cannot prove the gameplay state, place and arm a `UloopPausePoint.Hit("")` marker after the input-handling path, run the input again, then inspect while Unity is paused +5. Report what happened ## Tool Reference diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md index da66d0b1fd..f1e9a51889 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md @@ -15,7 +15,8 @@ Simulate mouse input via Input System in Unity PlayMode: $ARGUMENTS 2. For Click/LongPress: determine the target screen position (use `uloop screenshot` to find coordinates) 3. Execute the appropriate `uloop simulate-mouse-input` command 4. Take a screenshot to verify the result: `uloop screenshot --capture-mode rendering` -5. Report what happened +5. If the screenshot cannot prove the gameplay state, place and arm a `UloopPausePoint.Hit("")` marker after the input-handling path, run the input again, then inspect while Unity is paused +6. Report what happened ## Tool Reference diff --git a/cli/internal/cli/command_help.go b/cli/internal/cli/command_help.go index 9cf86f6733..c59aea94cd 100644 --- a/cli/internal/cli/command_help.go +++ b/cli/internal/cli/command_help.go @@ -211,7 +211,7 @@ func nativeCommandDescription(command string) (string, bool) { func nativeCommandUsesProject(command string) bool { switch command { - case launchCommandName, "list", "sync", "focus-window", skillsCommandName: + case launchCommandName, "list", "sync", "focus-window", skillsCommandName, pausePointWaitCommandName: return true default: return false diff --git a/cli/internal/cli/command_registry.go b/cli/internal/cli/command_registry.go index 78c7bafb50..cddb11029a 100644 --- a/cli/internal/cli/command_registry.go +++ b/cli/internal/cli/command_registry.go @@ -10,6 +10,7 @@ var nativeCommands = []nativeCommandEntry{ {name: "list", description: "Show Unity tools currently exposed by the Editor"}, {name: "sync", description: "Refresh .uloop/tools.json from the running Editor"}, {name: "focus-window", description: "Bring the Unity Editor window to the foreground"}, + {name: "wait-for-pause-point", description: "Wait until a named UloopPausePoint.Hit marker pauses Unity"}, {name: "skills", description: "List, install, or uninstall agent skills"}, {name: "completion", description: "Print or install shell completion"}, {name: "install", description: "Configure the global uloop launcher binary"}, diff --git a/cli/internal/cli/completion.go b/cli/internal/cli/completion.go index e9be29df91..1786d8d8ca 100644 --- a/cli/internal/cli/completion.go +++ b/cli/internal/cli/completion.go @@ -24,20 +24,6 @@ const ( pwshProfileSubpath = "Documents/PowerShell/Microsoft.PowerShell_profile.ps1" ) -var nativeCommandOptions = map[string][]string{ - completionCommand: {installCompletionFlag, shellFlag}, - launchCommandName: { - "--" + projectPathFlagName, - "--delete-recovery", - "--max-depth", - "--platform", - "--quit", - "--restart", - }, - installCommandName: {"--" + installDirFlagName}, - updateCommandName: {"--" + updateToVersionFlagName}, -} - func tryHandleCompletionRequest(args []string, cache toolsCache, stdout io.Writer, stderr io.Writer) (bool, int) { if len(args) == 0 { return false, 0 diff --git a/cli/internal/cli/completion_options.go b/cli/internal/cli/completion_options.go new file mode 100644 index 0000000000..fdf44906df --- /dev/null +++ b/cli/internal/cli/completion_options.go @@ -0,0 +1,20 @@ +package cli + +var nativeCommandOptions = map[string][]string{ + completionCommand: {installCompletionFlag, shellFlag}, + launchCommandName: { + "--" + projectPathFlagName, + "--delete-recovery", + "--max-depth", + "--platform", + "--quit", + "--restart", + }, + installCommandName: {"--" + installDirFlagName}, + updateCommandName: {"--" + updateToVersionFlagName}, + pausePointWaitCommandName: { + "--" + pausePointIDFlagName, + "--" + pausePointTimeoutFlagName, + "--" + projectPathFlagName, + }, +} diff --git a/cli/internal/cli/error_envelope.go b/cli/internal/cli/error_envelope.go index 0ae8169f76..c36a5a7d2d 100644 --- a/cli/internal/cli/error_envelope.go +++ b/cli/internal/cli/error_envelope.go @@ -23,6 +23,10 @@ const ( errorCodeCLIUpdateRequired = "CLI_UPDATE_REQUIRED" errorCodeCompileWaitTimeout = "COMPILE_WAIT_TIMEOUT" errorCodeControlPlayModeWaitTimeout = "CONTROL_PLAY_MODE_WAIT_TIMEOUT" + errorCodePausePointNotArmed = "PAUSE_POINT_NOT_ARMED" + errorCodePausePointWaitTimeout = "PAUSE_POINT_WAIT_TIMEOUT" + errorCodePausePointExpired = "PAUSE_POINT_EXPIRED" + errorCodePausePointCleared = "PAUSE_POINT_CLEARED" errorCodeInternalError = "INTERNAL_ERROR" errorPhaseArgumentParsing = "argument_parsing" diff --git a/cli/internal/cli/error_envelope_test.go b/cli/internal/cli/error_envelope_test.go index 83f384d978..78ee51b9a5 100644 --- a/cli/internal/cli/error_envelope_test.go +++ b/cli/internal/cli/error_envelope_test.go @@ -437,7 +437,7 @@ func TestClassifyConnectionAttemptUsesContextProjectRootFallback(t *testing.T) { func TestAvailableCommandNamesIncludesBuiltIns(t *testing.T) { names := availableCommandNames(toolsCache{}) - expectedBuiltIns := []string{"launch", "list", "sync", "focus-window", "skills", "completion", "install", "update"} + expectedBuiltIns := []string{"launch", "list", "sync", "focus-window", "wait-for-pause-point", "skills", "completion", "install", "update"} for index, expected := range expectedBuiltIns { if names[index] != expected { t.Fatalf("built-in command mismatch: %#v", names) diff --git a/cli/internal/cli/pause_point_wait.go b/cli/internal/cli/pause_point_wait.go new file mode 100644 index 0000000000..8e42207ab5 --- /dev/null +++ b/cli/internal/cli/pause_point_wait.go @@ -0,0 +1,364 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strconv" + "time" + + "github.com/hatayama/unity-cli-loop/cli/internal/unityipc" +) + +const ( + pausePointWaitCommandName = "wait-for-pause-point" + pausePointStatusCommandName = "get-pause-point-status" + pausePointClearStatusCommandName = "clear-pause-point-status" + pausePointIDFlagName = "id" + pausePointTimeoutFlagName = "timeout-seconds" + pausePointDefaultTimeoutSeconds = 30 + pausePointStatusProbeTimeout = 5 * time.Second + pausePointStatusArmed = "Armed" + pausePointStatusHit = "Hit" + pausePointStatusNotArmed = "NotArmed" + pausePointStatusExpired = "Expired" + pausePointStatusCleared = "Cleared" +) + +var ( + pausePointStatusPoll = 50 * time.Millisecond + queryPausePointStatus = queryPausePointStatusFromUnity + clearPausePointStatus = clearPausePointStatusFromUnity +) + +type waitForPausePointOptions struct { + id string + timeoutSeconds int + timeout time.Duration +} + +type pausePointStatusResponse struct { + Id string `json:"Id"` + Status string `json:"Status"` + IsArmed bool `json:"IsArmed"` + IsHit bool `json:"IsHit"` + HitCount int `json:"HitCount"` + TimeoutSeconds int `json:"TimeoutSeconds"` + ElapsedMilliseconds int64 `json:"ElapsedMilliseconds"` + IsPlaying bool `json:"IsPlaying"` + IsPaused bool `json:"IsPaused"` + Message string `json:"Message"` +} + +type pausePointWaitState string + +const ( + pausePointWaitStateHit pausePointWaitState = "hit" + pausePointWaitStateTimeout pausePointWaitState = "timeout" + pausePointWaitStateNotArmed pausePointWaitState = "not_armed" + pausePointWaitStateExpired pausePointWaitState = "expired" + pausePointWaitStateCleared pausePointWaitState = "cleared" +) + +func runWaitForPausePointCommand( + ctx context.Context, + connection unityipc.Connection, + args []string, + stdout io.Writer, + stderr io.Writer, +) int { + options, err := parseWaitForPausePointOptions(args) + if err != nil { + writeClassifiedError(stderr, err, errorContext{ + projectRoot: connection.ProjectRoot, + command: pausePointWaitCommandName, + }) + return 1 + } + + return runWaitForPausePoint(ctx, connection, options, stdout, stderr) +} + +func runWaitForPausePoint( + ctx context.Context, + connection unityipc.Connection, + options waitForPausePointOptions, + stdout io.Writer, + stderr io.Writer, +) int { + startedAt := time.Now() + spinner := newToolSpinner(stderr, pausePointWaitCommandName) + response, state, err := waitForPausePoint(ctx, connection, options) + spinner.Stop() + if err != nil { + writeClassifiedError(stderr, err, errorContext{ + projectRoot: connection.ProjectRoot, + command: pausePointWaitCommandName, + }) + return 1 + } + + if state == pausePointWaitStateHit { + result, marshalErr := json.Marshal(response) + if marshalErr != nil { + writeClassifiedError(stderr, marshalErr, errorContext{ + projectRoot: connection.ProjectRoot, + command: pausePointWaitCommandName, + }) + return 1 + } + writeJSON(stdout, result) + writeDebugTiming(stderr, pausePointWaitCommandName, time.Since(startedAt), unityipc.UnitySendOutcome{}) + return 0 + } + + if state == pausePointWaitStateTimeout { + clearPausePointAfterWaitTimeout(ctx, connection, options.id) + } + + writeErrorEnvelope(stderr, pausePointWaitError(connection.ProjectRoot, options, response, state)) + return 1 +} + +func parseWaitForPausePointOptions(args []string) (waitForPausePointOptions, error) { + options := waitForPausePointOptions{ + timeoutSeconds: pausePointDefaultTimeoutSeconds, + timeout: time.Duration(pausePointDefaultTimeoutSeconds) * time.Second, + } + + for index := 0; index < len(args); index++ { + arg := args[index] + name, value, consumedNext, err := parseFlagValue(arg, args, index) + if err != nil { + return waitForPausePointOptions{}, err + } + + switch name { + case pausePointIDFlagName: + options.id = value + case pausePointTimeoutFlagName: + timeoutSeconds, parseErr := parsePausePointTimeoutSeconds(value) + if parseErr != nil { + return waitForPausePointOptions{}, parseErr + } + options.timeoutSeconds = timeoutSeconds + options.timeout = time.Duration(timeoutSeconds) * time.Second + default: + return waitForPausePointOptions{}, &argumentError{ + message: "Unknown option for wait-for-pause-point: --" + name, + option: "--" + name, + command: pausePointWaitCommandName, + nextActions: []string{"Run `uloop wait-for-pause-point --help` to inspect supported options."}, + } + } + + if consumedNext { + index++ + } + } + + if options.id == "" { + return waitForPausePointOptions{}, &argumentError{ + message: "Missing required option: --id", + option: "--" + pausePointIDFlagName, + expectedType: "value", + command: pausePointWaitCommandName, + nextActions: []string{"Pass `--id ` matching UloopPausePoint.Hit(\"\")."}, + } + } + + return options, nil +} + +func parsePausePointTimeoutSeconds(value string) (int, error) { + timeoutSeconds, err := strconv.Atoi(value) + if err != nil || timeoutSeconds <= 0 { + return 0, invalidValueArgumentError("--"+pausePointTimeoutFlagName, value, "positive integer") + } + return timeoutSeconds, nil +} + +func waitForPausePoint( + ctx context.Context, + connection unityipc.Connection, + options waitForPausePointOptions, +) (pausePointStatusResponse, pausePointWaitState, error) { + waitContext, cancel := context.WithTimeout(ctx, options.timeout) + defer cancel() + + lastResponse := pausePointStatusResponse{Id: options.id} + var lastErr error + hasResponse := false + for { + response, err := queryPausePointStatus(waitContext, connection, options.id) + if err == nil { + lastResponse = response + hasResponse = true + state := pausePointWaitStateForStatus(response.Status) + if state != "" { + return response, state, nil + } + } else { + lastErr = err + } + + select { + case <-waitContext.Done(): + if ctx.Err() != nil { + return lastResponse, "", ctx.Err() + } + if hasResponse { + return lastResponse, pausePointWaitStateTimeout, nil + } + if lastErr != nil { + return lastResponse, "", fmt.Errorf("timed out waiting for pause point status: %w", lastErr) + } + return lastResponse, pausePointWaitStateTimeout, nil + case <-time.After(pausePointStatusPoll): + } + } +} + +func pausePointWaitStateForStatus(status string) pausePointWaitState { + switch status { + case pausePointStatusHit: + return pausePointWaitStateHit + case pausePointStatusNotArmed: + return pausePointWaitStateNotArmed + case pausePointStatusExpired: + return pausePointWaitStateExpired + case pausePointStatusCleared: + return pausePointWaitStateCleared + case pausePointStatusArmed: + return "" + default: + return "" + } +} + +func queryPausePointStatusFromUnity( + ctx context.Context, + connection unityipc.Connection, + id string, +) (pausePointStatusResponse, error) { + probeContext, cancel := context.WithTimeout(ctx, pausePointStatusProbeTimeout) + defer cancel() + + result, err := unityipc.NewClient(connection, version).Send( + probeContext, + pausePointStatusCommandName, + map[string]any{"Id": id}, + ) + if err != nil { + return pausePointStatusResponse{}, err + } + + response := pausePointStatusResponse{} + if err := json.Unmarshal(result, &response); err != nil { + return pausePointStatusResponse{}, err + } + return response, nil +} + +func clearPausePointStatusFromUnity( + ctx context.Context, + connection unityipc.Connection, + id string, +) (pausePointStatusResponse, error) { + probeContext, cancel := context.WithTimeout(ctx, pausePointStatusProbeTimeout) + defer cancel() + + result, err := unityipc.NewClient(connection, version).Send( + probeContext, + pausePointClearStatusCommandName, + map[string]any{"Id": id}, + ) + if err != nil { + return pausePointStatusResponse{}, err + } + + response := pausePointStatusResponse{} + if err := json.Unmarshal(result, &response); err != nil { + return pausePointStatusResponse{}, err + } + return response, nil +} + +func clearPausePointAfterWaitTimeout(ctx context.Context, connection unityipc.Connection, id string) { + clearContext, cancel := context.WithTimeout(ctx, pausePointStatusProbeTimeout) + defer cancel() + _, _ = clearPausePointStatus(clearContext, connection, id) +} + +func pausePointWaitError( + projectRoot string, + options waitForPausePointOptions, + response pausePointStatusResponse, + state pausePointWaitState, +) cliError { + switch state { + case pausePointWaitStateNotArmed: + return pausePointStateError( + errorCodePausePointNotArmed, + "Pause point is not armed.", + projectRoot, + options, + response, + false) + case pausePointWaitStateExpired: + return pausePointStateError( + errorCodePausePointExpired, + "Pause point expired before it was hit.", + projectRoot, + options, + response, + true) + case pausePointWaitStateCleared: + return pausePointStateError( + errorCodePausePointCleared, + "Pause point was cleared before it was hit.", + projectRoot, + options, + response, + true) + default: + return pausePointStateError( + errorCodePausePointWaitTimeout, + fmt.Sprintf("Pause point was not hit within %ds.", options.timeoutSeconds), + projectRoot, + options, + response, + true) + } +} + +func pausePointStateError( + errorCode string, + message string, + projectRoot string, + options waitForPausePointOptions, + response pausePointStatusResponse, + retryable bool, +) cliError { + return cliError{ + ErrorCode: errorCode, + Phase: errorPhaseResponseWaiting, + Message: message, + Retryable: retryable, + SafeToRetry: retryable, + ProjectRoot: projectRoot, + Command: pausePointWaitCommandName, + NextActions: []string{ + "Run `uloop arm-pause-point --id ` before waiting.", + "Confirm the code path calls `UloopPausePoint.Hit(\"\")` with the same id.", + }, + Details: map[string]any{ + "id": options.id, + "status": response.Status, + "hitCount": response.HitCount, + "timeoutSeconds": options.timeoutSeconds, + "elapsedMilliseconds": response.ElapsedMilliseconds, + }, + } +} diff --git a/cli/internal/cli/pause_point_wait_test.go b/cli/internal/cli/pause_point_wait_test.go new file mode 100644 index 0000000000..94dd3bb47b --- /dev/null +++ b/cli/internal/cli/pause_point_wait_test.go @@ -0,0 +1,150 @@ +package cli + +import ( + "bytes" + "context" + "strings" + "testing" + "time" + + "github.com/hatayama/unity-cli-loop/cli/internal/unityipc" +) + +// Verifies wait-for-pause-point polls until Unity reports the marker hit. +func TestWaitForPausePointReturnsHitAfterArmedStatus(t *testing.T) { + originalQuery := queryPausePointStatus + originalPoll := pausePointStatusPoll + pausePointStatusPoll = time.Millisecond + defer func() { + queryPausePointStatus = originalQuery + pausePointStatusPoll = originalPoll + }() + + responses := []pausePointStatusResponse{ + {Id: "jump", Status: pausePointStatusArmed, IsArmed: true}, + {Id: "jump", Status: pausePointStatusHit, IsHit: true, IsPaused: true, HitCount: 1}, + } + requestCount := 0 + queryPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + if id != "jump" { + t.Fatalf("id mismatch: %s", id) + } + response := responses[requestCount] + requestCount++ + return response, nil + } + + response, state, err := waitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ + id: "jump", + timeoutSeconds: 1, + timeout: time.Second, + }) + if err != nil { + t.Fatalf("waitForPausePoint failed: %v", err) + } + if state != pausePointWaitStateHit { + t.Fatalf("state mismatch: %s", state) + } + if response.Status != pausePointStatusHit || response.HitCount != 1 { + t.Fatalf("response mismatch: %#v", response) + } + if requestCount != 2 { + t.Fatalf("request count mismatch: %d", requestCount) + } +} + +// Verifies wait-for-pause-point clears the arm after its own timeout. +func TestRunWaitForPausePointClearsArmAfterTimeout(t *testing.T) { + originalQuery := queryPausePointStatus + originalClear := clearPausePointStatus + originalPoll := pausePointStatusPoll + pausePointStatusPoll = time.Millisecond + defer func() { + queryPausePointStatus = originalQuery + clearPausePointStatus = originalClear + pausePointStatusPoll = originalPoll + }() + + queryPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + return pausePointStatusResponse{Id: id, Status: pausePointStatusArmed, IsArmed: true}, nil + } + + clearedID := "" + clearPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + clearedID = id + return pausePointStatusResponse{Id: id, Status: pausePointStatusCleared}, nil + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runWaitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ + id: "jump", + timeoutSeconds: 1, + timeout: 5 * time.Millisecond, + }, &stdout, &stderr) + + if code != 1 { + t.Fatalf("expected failure, got %d with stdout %s", code, stdout.String()) + } + if clearedID != "jump" { + t.Fatalf("cleared id mismatch: %s", clearedID) + } + if !strings.Contains(stderr.String(), errorCodePausePointWaitTimeout) { + t.Fatalf("timeout error missing from stderr: %s", stderr.String()) + } +} + +// Verifies wait-for-pause-point rejects calls before the marker is armed. +func TestWaitForPausePointReturnsNotArmedStateImmediately(t *testing.T) { + originalQuery := queryPausePointStatus + defer func() { + queryPausePointStatus = originalQuery + }() + + queryPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + return pausePointStatusResponse{Id: id, Status: pausePointStatusNotArmed}, nil + } + + response, state, err := waitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ + id: "jump", + timeoutSeconds: 1, + timeout: time.Second, + }) + if err != nil { + t.Fatalf("waitForPausePoint failed: %v", err) + } + if state != pausePointWaitStateNotArmed { + t.Fatalf("state mismatch: %s", state) + } + if response.Status != pausePointStatusNotArmed { + t.Fatalf("response mismatch: %#v", response) + } +} + +// Verifies wait-for-pause-point requires a marker id. +func TestParseWaitForPausePointOptionsRequiresID(t *testing.T) { + _, err := parseWaitForPausePointOptions([]string{"--timeout-seconds", "1"}) + + if err == nil { + t.Fatal("expected missing id error") + } + if !strings.Contains(err.Error(), "Missing required option") { + t.Fatalf("error mismatch: %v", err) + } +} diff --git a/cli/internal/cli/run.go b/cli/internal/cli/run.go index 7f9ec89f3a..15fbb87b72 100644 --- a/cli/internal/cli/run.go +++ b/cli/internal/cli/run.go @@ -86,6 +86,8 @@ func RunProjectLocal(ctx context.Context, args []string, stdout io.Writer, stder return runSync(ctx, connection, stdout, stderr) case "focus-window": return runFocusWindow(ctx, connection.ProjectRoot, stdout, stderr) + case pausePointWaitCommandName: + return runWaitForPausePointCommand(ctx, connection, commandArgs, stdout, stderr) default: tool, cache, ok, err := findToolForCommand(connection.ProjectRoot, command) if err != nil { diff --git a/cli/internal/tools/default-tools.json b/cli/internal/tools/default-tools.json index 904923ef8f..cc64556644 100644 --- a/cli/internal/tools/default-tools.json +++ b/cli/internal/tools/default-tools.json @@ -328,6 +328,41 @@ } } }, + { + "name": "arm-pause-point", + "description": "Arm a named UloopPausePoint.Hit marker so Unity pauses when that code path is reached", + "inputSchema": { + "type": "object", + "properties": { + "Id": { + "type": "string", + "description": "Named pause point id passed to UloopPausePoint.Hit" + }, + "TimeoutSeconds": { + "type": "integer", + "description": "Seconds before the arm expires and stops pausing late hits", + "default": 30 + } + } + } + }, + { + "name": "clear-pause-point", + "description": "Clear one or all named UloopPausePoint.Hit markers", + "inputSchema": { + "type": "object", + "properties": { + "Id": { + "type": "string", + "description": "Named pause point id to clear" + }, + "All": { + "type": "boolean", + "description": "Clear every active pause point marker" + } + } + } + }, { "name": "simulate-mouse-ui", "description": "Simulate mouse click, long-press, and drag on PlayMode UI elements via EventSystem screen coordinates", From 466d2e71194c91d54da582ec93be538b19dab48f Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 3 Jun 2026 14:35:43 +0900 Subject: [PATCH 03/26] Rename pause point API to UnityCliLoopDebug.Break Align the public marker API with the Unity CLI Loop C# naming style and keep future debug helpers under UnityCliLoopDebug. --- Assets/Tests/Editor/PausePointTests.cs | 18 +++++++++--------- .../CliOnlyTools~/PausePoint/Skill/SKILL.md | 6 +++--- .../PausePoint/PausePointTools.cs | 2 +- .../SimulateKeyboard/Skill/SKILL.md | 2 +- .../SimulateMouseInput/Skill/SKILL.md | 2 +- ...UloopPausePoint.cs => UnityCliLoopDebug.cs} | 8 ++++---- ...Point.cs.meta => UnityCliLoopDebug.cs.meta} | 0 cli/internal/cli/command_registry.go | 2 +- cli/internal/cli/pause_point_wait.go | 4 ++-- cli/internal/tools/default-tools.json | 6 +++--- 10 files changed, 25 insertions(+), 25 deletions(-) rename Packages/src/Runtime/PausePoints/{UloopPausePoint.cs => UnityCliLoopDebug.cs} (54%) rename Packages/src/Runtime/PausePoints/{UloopPausePoint.cs.meta => UnityCliLoopDebug.cs.meta} (100%) diff --git a/Assets/Tests/Editor/PausePointTests.cs b/Assets/Tests/Editor/PausePointTests.cs index 5cbf622a59..509293a488 100644 --- a/Assets/Tests/Editor/PausePointTests.cs +++ b/Assets/Tests/Editor/PausePointTests.cs @@ -30,10 +30,10 @@ public void TearDown() } [Test] - public void Hit_WhenPausePointIsNotArmed_DoesNotPause() + public void Break_WhenPausePointIsNotArmed_DoesNotPause() { // Verifies marker calls are no-op until the CLI arms the same id. - UloopPausePoint.Hit("jump"); + UnityCliLoopDebug.Break("jump"); UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("jump"); @@ -43,12 +43,12 @@ public void Hit_WhenPausePointIsNotArmed_DoesNotPause() } [Test] - public void Hit_WhenPausePointIsArmed_RecordsHitAndRequestsPause() + public void Break_WhenPausePointIsArmed_RecordsHitAndRequestsPause() { // Verifies an armed marker hit records state and requests a Unity pause. UloopPausePointRegistry.Arm("jump", 30); - UloopPausePoint.Hit("jump"); + UnityCliLoopDebug.Break("jump"); UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("jump"); Assert.That(_pauseController.PauseCount, Is.EqualTo(1)); @@ -67,7 +67,7 @@ public void GetStatus_WhenTimeoutPasses_ExpiresAndDisarms() _nowUtc = _nowUtc.AddSeconds(2); UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("jump"); - UloopPausePoint.Hit("jump"); + UnityCliLoopDebug.Break("jump"); Assert.That(snapshot.Status, Is.EqualTo(UloopPausePointStatus.Expired)); Assert.That(snapshot.IsArmed, Is.False); @@ -81,7 +81,7 @@ public void Clear_WhenPausePointIsArmed_DisarmsWithoutPause() UloopPausePointRegistry.Arm("jump", 30); UloopPausePointSnapshot snapshot = UloopPausePointRegistry.Clear("jump"); - UloopPausePoint.Hit("jump"); + UnityCliLoopDebug.Break("jump"); Assert.That(snapshot.Status, Is.EqualTo(UloopPausePointStatus.Cleared)); Assert.That(snapshot.IsArmed, Is.False); @@ -89,16 +89,16 @@ public void Clear_WhenPausePointIsArmed_DisarmsWithoutPause() } [Test] - public void HitMethod_WhenSourceIsScanned_UsesUnityEditorConditionalWithoutDebugBreak() + public void BreakMethod_WhenSourceIsScanned_UsesUnityEditorConditionalWithoutDebugBreak() { // Verifies the public marker follows Unity's conditional call-site removal pattern. string sourcePath = Path.Combine( Directory.GetCurrentDirectory(), - "Packages/src/Runtime/PausePoints/UloopPausePoint.cs"); + "Packages/src/Runtime/PausePoints/UnityCliLoopDebug.cs"); string source = File.ReadAllText(sourcePath); Assert.That(source, Does.Contain("[Conditional(\"UNITY_EDITOR\")]")); - Assert.That(source, Does.Contain("public static void Hit(string id)")); + Assert.That(source, Does.Contain("public static void Break(string id)")); Assert.That(source, Does.Not.Contain("Debug.Break")); } diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md index 467d7e607e..427af3a1b9 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -1,6 +1,6 @@ --- name: uloop-wait-for-pause-point -description: "Pause Unity at a named UloopPausePoint.Hit marker when input or gameplay state is hard to verify." +description: "Pause Unity at a named UnityCliLoopDebug.Break marker when input or gameplay state is hard to verify." --- # uloop wait-for-pause-point @@ -20,7 +20,7 @@ Pause Unity when execution reaches a named marker in user code. ```csharp using io.github.hatayama.UnityCliLoop.Runtime; -UloopPausePoint.Hit("player-jumped"); +UnityCliLoopDebug.Break("player-jumped"); ``` 2. Compile the project. @@ -46,6 +46,6 @@ uloop clear-pause-point --id player-jumped ## Safety -- `UloopPausePoint.Hit` uses Unity's conditional-call pattern and is compiled out of non-Editor call sites. +- `UnityCliLoopDebug.Break` uses Unity's conditional-call pattern and is compiled out of non-Editor call sites. - Do not pass side-effect expressions as the id argument. Use stable string ids. - This does not collect logs or state snapshots. Use existing inspection commands after Unity pauses. diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs index f52aa74fe6..1986e733c3 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs @@ -13,7 +13,7 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// public class ArmPausePointSchema : UnityCliLoopToolSchema { - [Description("Named pause point id passed to UloopPausePoint.Hit.")] + [Description("Named pause point id passed to UnityCliLoopDebug.Break.")] public string Id { get; set; } = string.Empty; [Description("Seconds before the arm expires and stops pausing late hits.")] diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md index 5f0d32c21b..75fcdce2a9 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md @@ -14,7 +14,7 @@ Simulate keyboard input on Unity PlayMode: $ARGUMENTS 1. Ensure Unity is in PlayMode (use `uloop control-play-mode --action Play` if not) 2. Execute the appropriate `uloop simulate-keyboard` command 3. Take a screenshot to verify the result: `uloop screenshot --capture-mode rendering` -4. If the screenshot cannot prove the gameplay state, place and arm a `UloopPausePoint.Hit("")` marker after the input-handling path, run the input again, then inspect while Unity is paused +4. If the screenshot cannot prove the gameplay state, place and arm a `UnityCliLoopDebug.Break("")` marker after the input-handling path, run the input again, then inspect while Unity is paused 5. Report what happened ## Tool Reference diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md index f1e9a51889..b48c9f81a7 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md @@ -15,7 +15,7 @@ Simulate mouse input via Input System in Unity PlayMode: $ARGUMENTS 2. For Click/LongPress: determine the target screen position (use `uloop screenshot` to find coordinates) 3. Execute the appropriate `uloop simulate-mouse-input` command 4. Take a screenshot to verify the result: `uloop screenshot --capture-mode rendering` -5. If the screenshot cannot prove the gameplay state, place and arm a `UloopPausePoint.Hit("")` marker after the input-handling path, run the input again, then inspect while Unity is paused +5. If the screenshot cannot prove the gameplay state, place and arm a `UnityCliLoopDebug.Break("")` marker after the input-handling path, run the input again, then inspect while Unity is paused 6. Report what happened ## Tool Reference diff --git a/Packages/src/Runtime/PausePoints/UloopPausePoint.cs b/Packages/src/Runtime/PausePoints/UnityCliLoopDebug.cs similarity index 54% rename from Packages/src/Runtime/PausePoints/UloopPausePoint.cs rename to Packages/src/Runtime/PausePoints/UnityCliLoopDebug.cs index 2ece9a7a2c..ce851765d4 100644 --- a/Packages/src/Runtime/PausePoints/UloopPausePoint.cs +++ b/Packages/src/Runtime/PausePoints/UnityCliLoopDebug.cs @@ -3,15 +3,15 @@ namespace io.github.hatayama.UnityCliLoop.Runtime { /// - /// Marks source locations that the Unity CLI can arm as temporary Editor pause points. + /// Provides Editor-only debug helpers for Unity CLI Loop workflows. /// - public static class UloopPausePoint + public static class UnityCliLoopDebug { /// - /// Records a named pause point hit when the Editor has armed the same id. + /// Breaks at a named marker when the Editor has armed the same id. /// [Conditional("UNITY_EDITOR")] - public static void Hit(string id) + public static void Break(string id) { #if UNITY_EDITOR UloopPausePointRegistry.Hit(id); diff --git a/Packages/src/Runtime/PausePoints/UloopPausePoint.cs.meta b/Packages/src/Runtime/PausePoints/UnityCliLoopDebug.cs.meta similarity index 100% rename from Packages/src/Runtime/PausePoints/UloopPausePoint.cs.meta rename to Packages/src/Runtime/PausePoints/UnityCliLoopDebug.cs.meta diff --git a/cli/internal/cli/command_registry.go b/cli/internal/cli/command_registry.go index cddb11029a..6fdb4060d7 100644 --- a/cli/internal/cli/command_registry.go +++ b/cli/internal/cli/command_registry.go @@ -10,7 +10,7 @@ var nativeCommands = []nativeCommandEntry{ {name: "list", description: "Show Unity tools currently exposed by the Editor"}, {name: "sync", description: "Refresh .uloop/tools.json from the running Editor"}, {name: "focus-window", description: "Bring the Unity Editor window to the foreground"}, - {name: "wait-for-pause-point", description: "Wait until a named UloopPausePoint.Hit marker pauses Unity"}, + {name: "wait-for-pause-point", description: "Wait until a named UnityCliLoopDebug.Break marker pauses Unity"}, {name: "skills", description: "List, install, or uninstall agent skills"}, {name: "completion", description: "Print or install shell completion"}, {name: "install", description: "Configure the global uloop launcher binary"}, diff --git a/cli/internal/cli/pause_point_wait.go b/cli/internal/cli/pause_point_wait.go index 8e42207ab5..7ea6b3b504 100644 --- a/cli/internal/cli/pause_point_wait.go +++ b/cli/internal/cli/pause_point_wait.go @@ -164,7 +164,7 @@ func parseWaitForPausePointOptions(args []string) (waitForPausePointOptions, err option: "--" + pausePointIDFlagName, expectedType: "value", command: pausePointWaitCommandName, - nextActions: []string{"Pass `--id ` matching UloopPausePoint.Hit(\"\")."}, + nextActions: []string{"Pass `--id ` matching UnityCliLoopDebug.Break(\"\")."}, } } @@ -351,7 +351,7 @@ func pausePointStateError( Command: pausePointWaitCommandName, NextActions: []string{ "Run `uloop arm-pause-point --id ` before waiting.", - "Confirm the code path calls `UloopPausePoint.Hit(\"\")` with the same id.", + "Confirm the code path calls `UnityCliLoopDebug.Break(\"\")` with the same id.", }, Details: map[string]any{ "id": options.id, diff --git a/cli/internal/tools/default-tools.json b/cli/internal/tools/default-tools.json index cc64556644..08dcbe0553 100644 --- a/cli/internal/tools/default-tools.json +++ b/cli/internal/tools/default-tools.json @@ -330,13 +330,13 @@ }, { "name": "arm-pause-point", - "description": "Arm a named UloopPausePoint.Hit marker so Unity pauses when that code path is reached", + "description": "Arm a named UnityCliLoopDebug.Break marker so Unity pauses when that code path is reached", "inputSchema": { "type": "object", "properties": { "Id": { "type": "string", - "description": "Named pause point id passed to UloopPausePoint.Hit" + "description": "Named pause point id passed to UnityCliLoopDebug.Break" }, "TimeoutSeconds": { "type": "integer", @@ -348,7 +348,7 @@ }, { "name": "clear-pause-point", - "description": "Clear one or all named UloopPausePoint.Hit markers", + "description": "Clear one or all named UnityCliLoopDebug.Break markers", "inputSchema": { "type": "object", "properties": { From c18179cf512bf463e6cb84f3986b6200e88afef4 Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 3 Jun 2026 14:55:01 +0900 Subject: [PATCH 04/26] Fix pause point review findings Clear stale terminal pause point states during bulk clear, remove first-party schema description attributes, and keep the architecture dependency test aligned with the public pause point runtime assembly. --- .../Tests/Editor/OnionAssemblyDependencyTests.cs | 5 +++-- Assets/Tests/Editor/PausePointTests.cs | 15 +++++++++++++++ .../FirstPartyTools/PausePoint/PausePointTools.cs | 5 ----- .../PausePoints/UloopPausePointRegistry.cs | 13 +++++-------- 4 files changed, 23 insertions(+), 15 deletions(-) diff --git a/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs b/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs index 438a431a05..0acc69991b 100644 --- a/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs +++ b/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs @@ -567,9 +567,9 @@ public void PresentationAsmdef_WhenLoaded_DependsOnApplicationAndDoesNotReferenc } [Test] - public void InfrastructureAsmdef_WhenLoaded_DependsOnApplicationAndDoesNotReferencePresentation() + public void InfrastructureAsmdef_WhenLoaded_DependsOnApplicationRuntimeAndDoesNotReferencePresentation() { - // Tests that infrastructure and presentation remain sibling outer layers. + // Tests that infrastructure can bridge public runtime APIs while presentation remains a sibling outer layer. string[] references = ReadResolvedReferences("Packages/src/Editor/Infrastructure/UnityCLILoop.Infrastructure.asmdef"); Assert.That(references, Is.EquivalentTo(new[] @@ -577,6 +577,7 @@ public void InfrastructureAsmdef_WhenLoaded_DependsOnApplicationAndDoesNotRefere InternalApiBridgeAssemblyName, ApplicationAssemblyName, DomainAssemblyName, + PausePointsRuntimeAssemblyName, ToolContractsAssemblyName })); } diff --git a/Assets/Tests/Editor/PausePointTests.cs b/Assets/Tests/Editor/PausePointTests.cs index 509293a488..2405f3818f 100644 --- a/Assets/Tests/Editor/PausePointTests.cs +++ b/Assets/Tests/Editor/PausePointTests.cs @@ -88,6 +88,21 @@ public void Clear_WhenPausePointIsArmed_DisarmsWithoutPause() Assert.That(_pauseController.PauseCount, Is.EqualTo(0)); } + [Test] + public void ClearAll_WhenPausePointWasHit_ClearsTerminalStatus() + { + // Verifies bulk clear hides stale terminal hit status from future waits. + UloopPausePointRegistry.Arm("jump", 30); + UnityCliLoopDebug.Break("jump"); + + UloopPausePointClearAllResult result = UloopPausePointRegistry.ClearAll(); + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("jump"); + + Assert.That(result.ClearedCount, Is.EqualTo(1)); + Assert.That(snapshot.Status, Is.EqualTo(UloopPausePointStatus.Cleared)); + Assert.That(snapshot.IsHit, Is.False); + } + [Test] public void BreakMethod_WhenSourceIsScanned_UsesUnityEditorConditionalWithoutDebugBreak() { diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs index 1986e733c3..3c90aee319 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs @@ -1,5 +1,4 @@ using System; -using System.ComponentModel; using System.Threading; using System.Threading.Tasks; @@ -13,10 +12,8 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// public class ArmPausePointSchema : UnityCliLoopToolSchema { - [Description("Named pause point id passed to UnityCliLoopDebug.Break.")] public string Id { get; set; } = string.Empty; - [Description("Seconds before the arm expires and stops pausing late hits.")] public int TimeoutSeconds { get; set; } = UloopPausePointRegistry.DefaultTimeoutSeconds; } @@ -25,10 +22,8 @@ public class ArmPausePointSchema : UnityCliLoopToolSchema /// public class ClearPausePointSchema : UnityCliLoopToolSchema { - [Description("Named pause point id to clear.")] public string Id { get; set; } = string.Empty; - [Description("Clear every active pause point marker.")] public bool All { get; set; } } diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs index a3adf224d5..89151fd212 100644 --- a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs +++ b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs @@ -49,11 +49,13 @@ public static UloopPausePointClearAllResult ClearAll() int clearedCount = 0; foreach (UloopPausePointEntry entry in Entries.Values) { - if (!entry.IsTerminal) + if (entry.Status == UloopPausePointStatus.Cleared) { - entry.MarkCleared(); - clearedCount++; + continue; } + + entry.MarkCleared(); + clearedCount++; } return new UloopPausePointClearAllResult(clearedCount, now); @@ -248,11 +250,6 @@ public UloopPausePointEntry(string id, int timeoutSeconds, DateTime armedAtUtc) public bool IsPausedAtHit { get; private set; } public string Message { get; private set; } - public bool IsTerminal => - Status == UloopPausePointStatus.Hit || - Status == UloopPausePointStatus.Expired || - Status == UloopPausePointStatus.Cleared; - public void ExpireIfNeeded(DateTime nowUtc) { if (!IsArmed) From 2a306750fa41ddd7d303a4bd57c69254d758a31c Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 3 Jun 2026 15:46:11 +0900 Subject: [PATCH 05/26] Rename debug break CLI commands Align the command names with UnityCliLoopDebug.Break by exposing arm-debug-break, wait-for-debug-break, and clear-debug-break instead of the pause-point wording. --- .../Editor/UnityCliLoopToolRegistryTests.cs | 8 +- .../CliOnlyTools~/PausePoint/Skill/SKILL.md | 10 +- .../PausePoint/PausePointTools.cs | 18 +- .../SimulateKeyboard/Skill/SKILL.md | 2 +- .../SimulateMouseInput/Skill/SKILL.md | 2 +- .../Api/InternalBridgeCommandRouter.cs | 8 +- .../Api/PausePointStatusBridgeCommand.cs | 4 +- .../ToolContracts/UnityCliLoopConstants.cs | 8 +- .../PausePoints/UloopPausePointRegistry.cs | 10 +- cli/internal/cli/command_help.go | 2 +- cli/internal/cli/command_registry.go | 2 +- cli/internal/cli/completion_options.go | 6 +- cli/internal/cli/debug_break_wait.go | 364 ++++++++++++++++++ cli/internal/cli/debug_break_wait_test.go | 150 ++++++++ cli/internal/cli/error_envelope.go | 8 +- cli/internal/cli/error_envelope_test.go | 2 +- cli/internal/cli/pause_point_wait.go | 364 ------------------ cli/internal/cli/pause_point_wait_test.go | 150 -------- cli/internal/cli/run.go | 4 +- cli/internal/tools/default-tools.json | 10 +- 20 files changed, 566 insertions(+), 566 deletions(-) create mode 100644 cli/internal/cli/debug_break_wait.go create mode 100644 cli/internal/cli/debug_break_wait_test.go delete mode 100644 cli/internal/cli/pause_point_wait.go delete mode 100644 cli/internal/cli/pause_point_wait_test.go diff --git a/Assets/Tests/Editor/UnityCliLoopToolRegistryTests.cs b/Assets/Tests/Editor/UnityCliLoopToolRegistryTests.cs index a274e7460e..652dfc9640 100644 --- a/Assets/Tests/Editor/UnityCliLoopToolRegistryTests.cs +++ b/Assets/Tests/Editor/UnityCliLoopToolRegistryTests.cs @@ -54,8 +54,8 @@ public void Constructor_WhenFirstPartyToolsUseToolAttribute_RegistersThem() Assert.That(registry.IsToolRegistered("compile"), Is.True); Assert.That(registry.IsToolRegistered("get-logs"), Is.True); - Assert.That(registry.IsToolRegistered(UnityCliLoopConstants.TOOL_NAME_ARM_PAUSE_POINT), Is.True); - Assert.That(registry.IsToolRegistered(UnityCliLoopConstants.TOOL_NAME_CLEAR_PAUSE_POINT), Is.True); + Assert.That(registry.IsToolRegistered(UnityCliLoopConstants.TOOL_NAME_ARM_DEBUG_BREAK), Is.True); + Assert.That(registry.IsToolRegistered(UnityCliLoopConstants.TOOL_NAME_CLEAR_DEBUG_BREAK), Is.True); Assert.That(registry.IsToolRegistered("execute-dynamic-code"), Is.True); Assert.That(registry.IsToolRegistered("clear-console"), Is.True); Assert.That(registry.IsToolRegistered("get-hierarchy"), Is.True); @@ -257,8 +257,8 @@ public void GetToolType_WhenPausePointComesFromFirstPartyToolsAssembly_ReturnsBu // Tests that pause point tools are bundled plugins instead of application-layer tools. UnityCliLoopToolRegistry registry = ToolRegistryTestFactory.Create(); - System.Type armToolType = registry.GetToolType(UnityCliLoopConstants.TOOL_NAME_ARM_PAUSE_POINT); - System.Type clearToolType = registry.GetToolType(UnityCliLoopConstants.TOOL_NAME_CLEAR_PAUSE_POINT); + System.Type armToolType = registry.GetToolType(UnityCliLoopConstants.TOOL_NAME_ARM_DEBUG_BREAK); + System.Type clearToolType = registry.GetToolType(UnityCliLoopConstants.TOOL_NAME_CLEAR_DEBUG_BREAK); Assert.That(armToolType, Is.Not.Null); Assert.That(clearToolType, Is.Not.Null); diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md index 427af3a1b9..6045ac1ad5 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -1,9 +1,9 @@ --- -name: uloop-wait-for-pause-point +name: uloop-wait-for-debug-break description: "Pause Unity at a named UnityCliLoopDebug.Break marker when input or gameplay state is hard to verify." --- -# uloop wait-for-pause-point +# uloop wait-for-debug-break Pause Unity when execution reaches a named marker in user code. @@ -27,21 +27,21 @@ UnityCliLoopDebug.Break("player-jumped"); 3. Arm the marker before triggering the code path: ```bash -uloop arm-pause-point --id player-jumped --timeout-seconds 30 +uloop arm-debug-break --id player-jumped --timeout-seconds 30 ``` 4. Trigger the behavior, such as `simulate-keyboard`, `simulate-mouse-input`, UI interaction, or dynamic code. 5. Wait for the marker: ```bash -uloop wait-for-pause-point --id player-jumped --timeout-seconds 30 +uloop wait-for-debug-break --id player-jumped --timeout-seconds 30 ``` 6. While Unity is paused, inspect state with `uloop get-logs`, `uloop get-hierarchy`, `uloop find-game-objects`, screenshots, or `uloop execute-dynamic-code`. 7. Clear the marker if you stop waiting: ```bash -uloop clear-pause-point --id player-jumped +uloop clear-debug-break --id player-jumped ``` ## Safety diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs index 3c90aee319..413e8d47db 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs @@ -8,7 +8,7 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// - /// Parameters for arming one named pause point marker. + /// Parameters for arming one named debug break marker. /// public class ArmPausePointSchema : UnityCliLoopToolSchema { @@ -18,7 +18,7 @@ public class ArmPausePointSchema : UnityCliLoopToolSchema } /// - /// Parameters for clearing one or all pause point markers. + /// Parameters for clearing one or all debug break markers. /// public class ClearPausePointSchema : UnityCliLoopToolSchema { @@ -28,7 +28,7 @@ public class ClearPausePointSchema : UnityCliLoopToolSchema } /// - /// Response shared by pause point tool commands. + /// Response shared by debug break tool commands. /// public class PausePointResponse : UnityCliLoopToolResponse { @@ -77,18 +77,18 @@ internal static PausePointResponse FromClearAll(UloopPausePointClearAllResult re { Status = UloopPausePointStatus.Cleared, ClearedCount = result.ClearedCount, - Message = "Pause points cleared." + Message = "Debug breaks cleared." }; } } /// - /// Exposes pause point arming as a Unity CLI Loop tool. + /// Exposes debug break arming as a Unity CLI Loop tool. /// [UnityCliLoopTool] public class ArmPausePointTool : UnityCliLoopTool { - public override string ToolName => UnityCliLoopConstants.TOOL_NAME_ARM_PAUSE_POINT; + public override string ToolName => UnityCliLoopConstants.TOOL_NAME_ARM_DEBUG_BREAK; protected override Task ExecuteAsync(ArmPausePointSchema parameters, CancellationToken ct) { @@ -100,12 +100,12 @@ protected override Task ExecuteAsync(ArmPausePointSchema par } /// - /// Exposes pause point clearing as a Unity CLI Loop tool. + /// Exposes debug break clearing as a Unity CLI Loop tool. /// [UnityCliLoopTool] public class ClearPausePointTool : UnityCliLoopTool { - public override string ToolName => UnityCliLoopConstants.TOOL_NAME_CLEAR_PAUSE_POINT; + public override string ToolName => UnityCliLoopConstants.TOOL_NAME_CLEAR_DEBUG_BREAK; protected override Task ExecuteAsync(ClearPausePointSchema parameters, CancellationToken ct) { @@ -117,7 +117,7 @@ protected override Task ExecuteAsync(ClearPausePointSchema p } /// - /// Coordinates pause point tool validation and registry updates. + /// Coordinates debug break tool validation and registry updates. /// internal sealed class PausePointUseCase { diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md index 75fcdce2a9..d112075d26 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md @@ -14,7 +14,7 @@ Simulate keyboard input on Unity PlayMode: $ARGUMENTS 1. Ensure Unity is in PlayMode (use `uloop control-play-mode --action Play` if not) 2. Execute the appropriate `uloop simulate-keyboard` command 3. Take a screenshot to verify the result: `uloop screenshot --capture-mode rendering` -4. If the screenshot cannot prove the gameplay state, place and arm a `UnityCliLoopDebug.Break("")` marker after the input-handling path, run the input again, then inspect while Unity is paused +4. If the screenshot cannot prove the gameplay state, place and arm a `UnityCliLoopDebug.Break("")` marker with `uloop arm-debug-break`, run the input again, then inspect while Unity is paused 5. Report what happened ## Tool Reference diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md index b48c9f81a7..c259aa1ce1 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md @@ -15,7 +15,7 @@ Simulate mouse input via Input System in Unity PlayMode: $ARGUMENTS 2. For Click/LongPress: determine the target screen position (use `uloop screenshot` to find coordinates) 3. Execute the appropriate `uloop simulate-mouse-input` command 4. Take a screenshot to verify the result: `uloop screenshot --capture-mode rendering` -5. If the screenshot cannot prove the gameplay state, place and arm a `UnityCliLoopDebug.Break("")` marker after the input-handling path, run the input again, then inspect while Unity is paused +5. If the screenshot cannot prove the gameplay state, place and arm a `UnityCliLoopDebug.Break("")` marker with `uloop arm-debug-break`, run the input again, then inspect while Unity is paused 6. Report what happened ## Tool Reference diff --git a/Packages/src/Editor/Infrastructure/Api/InternalBridgeCommandRouter.cs b/Packages/src/Editor/Infrastructure/Api/InternalBridgeCommandRouter.cs index 0c2f0383f3..d66a8c6332 100644 --- a/Packages/src/Editor/Infrastructure/Api/InternalBridgeCommandRouter.cs +++ b/Packages/src/Editor/Infrastructure/Api/InternalBridgeCommandRouter.cs @@ -15,8 +15,8 @@ public static bool IsInternalCommand(string commandName) { return commandName == UnityCliLoopConstants.COMMAND_NAME_GET_VERSION || commandName == UnityCliLoopConstants.COMMAND_NAME_GET_COMPILE_STATUS || - commandName == UnityCliLoopConstants.COMMAND_NAME_GET_PAUSE_POINT_STATUS || - commandName == UnityCliLoopConstants.COMMAND_NAME_CLEAR_PAUSE_POINT_STATUS || + commandName == UnityCliLoopConstants.COMMAND_NAME_GET_DEBUG_BREAK_STATUS || + commandName == UnityCliLoopConstants.COMMAND_NAME_CLEAR_DEBUG_BREAK_STATUS || commandName == UnityCliLoopConstants.COMMAND_NAME_GET_TOOL_DETAILS; } @@ -39,12 +39,12 @@ public static UnityCliLoopToolResponse Execute(string commandName, JToken params return CompileStatusBridgeCommand.Execute(paramsToken); } - if (commandName == UnityCliLoopConstants.COMMAND_NAME_GET_PAUSE_POINT_STATUS) + if (commandName == UnityCliLoopConstants.COMMAND_NAME_GET_DEBUG_BREAK_STATUS) { return PausePointStatusBridgeCommand.Execute(paramsToken); } - if (commandName == UnityCliLoopConstants.COMMAND_NAME_CLEAR_PAUSE_POINT_STATUS) + if (commandName == UnityCliLoopConstants.COMMAND_NAME_CLEAR_DEBUG_BREAK_STATUS) { return PausePointStatusBridgeCommand.Clear(paramsToken); } diff --git a/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs b/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs index 32948ed74a..6d8b740de6 100644 --- a/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs +++ b/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs @@ -7,7 +7,7 @@ namespace io.github.hatayama.UnityCliLoop.Infrastructure { /// - /// Serves CLI-only pause point status and cleanup requests outside the normal tool slot. + /// Serves CLI-only debug break status and cleanup requests outside the normal tool slot. /// internal static class PausePointStatusBridgeCommand { @@ -40,7 +40,7 @@ private static string ReadId(JToken paramsToken) } /// - /// Pause point status payload returned by the internal CLI polling bridge command. + /// Debug break status payload returned by the internal CLI polling bridge command. /// public class PausePointStatusResponse : UnityCliLoopToolResponse { diff --git a/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs b/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs index 5f9a92d075..aa31386424 100644 --- a/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs +++ b/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs @@ -61,8 +61,8 @@ public static UnityEditor.PackageManager.PackageInfo PackageInfo // Command name constants public const string TOOL_NAME_COMPILE = "compile"; public const string TOOL_NAME_CONTROL_PLAY_MODE = "control-play-mode"; - public const string TOOL_NAME_ARM_PAUSE_POINT = "arm-pause-point"; - public const string TOOL_NAME_CLEAR_PAUSE_POINT = "clear-pause-point"; + public const string TOOL_NAME_ARM_DEBUG_BREAK = "arm-debug-break"; + public const string TOOL_NAME_CLEAR_DEBUG_BREAK = "clear-debug-break"; public const string TOOL_NAME_EXECUTE_DYNAMIC_CODE = "execute-dynamic-code"; public const string TOOL_NAME_GET_HIERARCHY = "get-hierarchy"; public const string TOOL_NAME_GET_LOGS = "get-logs"; @@ -70,8 +70,8 @@ public static UnityEditor.PackageManager.PackageInfo PackageInfo public const string COMMAND_NAME_GET_TOOL_DETAILS = "get-tool-details"; public const string COMMAND_NAME_GET_VERSION = "get-version"; public const string COMMAND_NAME_GET_COMPILE_STATUS = "get-compile-status"; - public const string COMMAND_NAME_GET_PAUSE_POINT_STATUS = "get-pause-point-status"; - public const string COMMAND_NAME_CLEAR_PAUSE_POINT_STATUS = "clear-pause-point-status"; + public const string COMMAND_NAME_GET_DEBUG_BREAK_STATUS = "get-debug-break-status"; + public const string COMMAND_NAME_CLEAR_DEBUG_BREAK_STATUS = "clear-debug-break-status"; // Optional package names public const string PACKAGE_NAME_TEST_FRAMEWORK = "com.unity.test-framework"; diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs index 89151fd212..6240aa6db6 100644 --- a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs +++ b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs @@ -203,7 +203,7 @@ public static UloopPausePointSnapshot NotArmed(string id, IUloopPausePointPauseC 0, pauseController.IsPlaying, pauseController.IsPaused, - "Pause point is not armed."); + "Debug break is not armed."); } } @@ -235,7 +235,7 @@ public UloopPausePointEntry(string id, int timeoutSeconds, DateTime armedAtUtc) ExpiresAtUtc = armedAtUtc.AddSeconds(timeoutSeconds); Status = UloopPausePointStatus.Armed; IsArmed = true; - Message = "Pause point armed."; + Message = "Debug break armed."; } public string Id { get; } @@ -264,14 +264,14 @@ public void ExpireIfNeeded(DateTime nowUtc) IsArmed = false; Status = UloopPausePointStatus.Expired; - Message = "Pause point expired before it was hit."; + Message = "Debug break expired before it was hit."; } public void MarkCleared() { IsArmed = false; Status = UloopPausePointStatus.Cleared; - Message = "Pause point cleared."; + Message = "Debug break cleared."; } public void RecordHit(DateTime nowUtc, bool isPlaying, bool isPaused) @@ -282,7 +282,7 @@ public void RecordHit(DateTime nowUtc, bool isPlaying, bool isPaused) IsPausedAtHit = isPaused; IsArmed = false; Status = UloopPausePointStatus.Hit; - Message = "Pause point hit; Unity pause was requested."; + Message = "Debug break hit; Unity pause was requested."; } public UloopPausePointSnapshot ToSnapshot(DateTime nowUtc, IUloopPausePointPauseController pauseController) diff --git a/cli/internal/cli/command_help.go b/cli/internal/cli/command_help.go index c59aea94cd..ff69e984c5 100644 --- a/cli/internal/cli/command_help.go +++ b/cli/internal/cli/command_help.go @@ -211,7 +211,7 @@ func nativeCommandDescription(command string) (string, bool) { func nativeCommandUsesProject(command string) bool { switch command { - case launchCommandName, "list", "sync", "focus-window", skillsCommandName, pausePointWaitCommandName: + case launchCommandName, "list", "sync", "focus-window", skillsCommandName, debugBreakWaitCommandName: return true default: return false diff --git a/cli/internal/cli/command_registry.go b/cli/internal/cli/command_registry.go index 6fdb4060d7..4dd88d7449 100644 --- a/cli/internal/cli/command_registry.go +++ b/cli/internal/cli/command_registry.go @@ -10,7 +10,7 @@ var nativeCommands = []nativeCommandEntry{ {name: "list", description: "Show Unity tools currently exposed by the Editor"}, {name: "sync", description: "Refresh .uloop/tools.json from the running Editor"}, {name: "focus-window", description: "Bring the Unity Editor window to the foreground"}, - {name: "wait-for-pause-point", description: "Wait until a named UnityCliLoopDebug.Break marker pauses Unity"}, + {name: "wait-for-debug-break", description: "Wait until a named UnityCliLoopDebug.Break marker pauses Unity"}, {name: "skills", description: "List, install, or uninstall agent skills"}, {name: "completion", description: "Print or install shell completion"}, {name: "install", description: "Configure the global uloop launcher binary"}, diff --git a/cli/internal/cli/completion_options.go b/cli/internal/cli/completion_options.go index fdf44906df..9da0fbcd47 100644 --- a/cli/internal/cli/completion_options.go +++ b/cli/internal/cli/completion_options.go @@ -12,9 +12,9 @@ var nativeCommandOptions = map[string][]string{ }, installCommandName: {"--" + installDirFlagName}, updateCommandName: {"--" + updateToVersionFlagName}, - pausePointWaitCommandName: { - "--" + pausePointIDFlagName, - "--" + pausePointTimeoutFlagName, + debugBreakWaitCommandName: { + "--" + debugBreakIDFlagName, + "--" + debugBreakTimeoutFlagName, "--" + projectPathFlagName, }, } diff --git a/cli/internal/cli/debug_break_wait.go b/cli/internal/cli/debug_break_wait.go new file mode 100644 index 0000000000..6ca6d08a99 --- /dev/null +++ b/cli/internal/cli/debug_break_wait.go @@ -0,0 +1,364 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strconv" + "time" + + "github.com/hatayama/unity-cli-loop/cli/internal/unityipc" +) + +const ( + debugBreakWaitCommandName = "wait-for-debug-break" + debugBreakStatusCommandName = "get-debug-break-status" + debugBreakClearStatusCommandName = "clear-debug-break-status" + debugBreakIDFlagName = "id" + debugBreakTimeoutFlagName = "timeout-seconds" + debugBreakDefaultTimeoutSeconds = 30 + debugBreakStatusProbeTimeout = 5 * time.Second + debugBreakStatusArmed = "Armed" + debugBreakStatusHit = "Hit" + debugBreakStatusNotArmed = "NotArmed" + debugBreakStatusExpired = "Expired" + debugBreakStatusCleared = "Cleared" +) + +var ( + debugBreakStatusPoll = 50 * time.Millisecond + queryDebugBreakStatus = queryDebugBreakStatusFromUnity + clearDebugBreakStatus = clearDebugBreakStatusFromUnity +) + +type waitForDebugBreakOptions struct { + id string + timeoutSeconds int + timeout time.Duration +} + +type debugBreakStatusResponse struct { + Id string `json:"Id"` + Status string `json:"Status"` + IsArmed bool `json:"IsArmed"` + IsHit bool `json:"IsHit"` + HitCount int `json:"HitCount"` + TimeoutSeconds int `json:"TimeoutSeconds"` + ElapsedMilliseconds int64 `json:"ElapsedMilliseconds"` + IsPlaying bool `json:"IsPlaying"` + IsPaused bool `json:"IsPaused"` + Message string `json:"Message"` +} + +type debugBreakWaitState string + +const ( + debugBreakWaitStateHit debugBreakWaitState = "hit" + debugBreakWaitStateTimeout debugBreakWaitState = "timeout" + debugBreakWaitStateNotArmed debugBreakWaitState = "not_armed" + debugBreakWaitStateExpired debugBreakWaitState = "expired" + debugBreakWaitStateCleared debugBreakWaitState = "cleared" +) + +func runWaitForDebugBreakCommand( + ctx context.Context, + connection unityipc.Connection, + args []string, + stdout io.Writer, + stderr io.Writer, +) int { + options, err := parseWaitForDebugBreakOptions(args) + if err != nil { + writeClassifiedError(stderr, err, errorContext{ + projectRoot: connection.ProjectRoot, + command: debugBreakWaitCommandName, + }) + return 1 + } + + return runWaitForDebugBreak(ctx, connection, options, stdout, stderr) +} + +func runWaitForDebugBreak( + ctx context.Context, + connection unityipc.Connection, + options waitForDebugBreakOptions, + stdout io.Writer, + stderr io.Writer, +) int { + startedAt := time.Now() + spinner := newToolSpinner(stderr, debugBreakWaitCommandName) + response, state, err := waitForDebugBreak(ctx, connection, options) + spinner.Stop() + if err != nil { + writeClassifiedError(stderr, err, errorContext{ + projectRoot: connection.ProjectRoot, + command: debugBreakWaitCommandName, + }) + return 1 + } + + if state == debugBreakWaitStateHit { + result, marshalErr := json.Marshal(response) + if marshalErr != nil { + writeClassifiedError(stderr, marshalErr, errorContext{ + projectRoot: connection.ProjectRoot, + command: debugBreakWaitCommandName, + }) + return 1 + } + writeJSON(stdout, result) + writeDebugTiming(stderr, debugBreakWaitCommandName, time.Since(startedAt), unityipc.UnitySendOutcome{}) + return 0 + } + + if state == debugBreakWaitStateTimeout { + clearDebugBreakAfterWaitTimeout(ctx, connection, options.id) + } + + writeErrorEnvelope(stderr, debugBreakWaitError(connection.ProjectRoot, options, response, state)) + return 1 +} + +func parseWaitForDebugBreakOptions(args []string) (waitForDebugBreakOptions, error) { + options := waitForDebugBreakOptions{ + timeoutSeconds: debugBreakDefaultTimeoutSeconds, + timeout: time.Duration(debugBreakDefaultTimeoutSeconds) * time.Second, + } + + for index := 0; index < len(args); index++ { + arg := args[index] + name, value, consumedNext, err := parseFlagValue(arg, args, index) + if err != nil { + return waitForDebugBreakOptions{}, err + } + + switch name { + case debugBreakIDFlagName: + options.id = value + case debugBreakTimeoutFlagName: + timeoutSeconds, parseErr := parseDebugBreakTimeoutSeconds(value) + if parseErr != nil { + return waitForDebugBreakOptions{}, parseErr + } + options.timeoutSeconds = timeoutSeconds + options.timeout = time.Duration(timeoutSeconds) * time.Second + default: + return waitForDebugBreakOptions{}, &argumentError{ + message: "Unknown option for wait-for-debug-break: --" + name, + option: "--" + name, + command: debugBreakWaitCommandName, + nextActions: []string{"Run `uloop wait-for-debug-break --help` to inspect supported options."}, + } + } + + if consumedNext { + index++ + } + } + + if options.id == "" { + return waitForDebugBreakOptions{}, &argumentError{ + message: "Missing required option: --id", + option: "--" + debugBreakIDFlagName, + expectedType: "value", + command: debugBreakWaitCommandName, + nextActions: []string{"Pass `--id ` matching UnityCliLoopDebug.Break(\"\")."}, + } + } + + return options, nil +} + +func parseDebugBreakTimeoutSeconds(value string) (int, error) { + timeoutSeconds, err := strconv.Atoi(value) + if err != nil || timeoutSeconds <= 0 { + return 0, invalidValueArgumentError("--"+debugBreakTimeoutFlagName, value, "positive integer") + } + return timeoutSeconds, nil +} + +func waitForDebugBreak( + ctx context.Context, + connection unityipc.Connection, + options waitForDebugBreakOptions, +) (debugBreakStatusResponse, debugBreakWaitState, error) { + waitContext, cancel := context.WithTimeout(ctx, options.timeout) + defer cancel() + + lastResponse := debugBreakStatusResponse{Id: options.id} + var lastErr error + hasResponse := false + for { + response, err := queryDebugBreakStatus(waitContext, connection, options.id) + if err == nil { + lastResponse = response + hasResponse = true + state := debugBreakWaitStateForStatus(response.Status) + if state != "" { + return response, state, nil + } + } else { + lastErr = err + } + + select { + case <-waitContext.Done(): + if ctx.Err() != nil { + return lastResponse, "", ctx.Err() + } + if hasResponse { + return lastResponse, debugBreakWaitStateTimeout, nil + } + if lastErr != nil { + return lastResponse, "", fmt.Errorf("timed out waiting for debug break status: %w", lastErr) + } + return lastResponse, debugBreakWaitStateTimeout, nil + case <-time.After(debugBreakStatusPoll): + } + } +} + +func debugBreakWaitStateForStatus(status string) debugBreakWaitState { + switch status { + case debugBreakStatusHit: + return debugBreakWaitStateHit + case debugBreakStatusNotArmed: + return debugBreakWaitStateNotArmed + case debugBreakStatusExpired: + return debugBreakWaitStateExpired + case debugBreakStatusCleared: + return debugBreakWaitStateCleared + case debugBreakStatusArmed: + return "" + default: + return "" + } +} + +func queryDebugBreakStatusFromUnity( + ctx context.Context, + connection unityipc.Connection, + id string, +) (debugBreakStatusResponse, error) { + probeContext, cancel := context.WithTimeout(ctx, debugBreakStatusProbeTimeout) + defer cancel() + + result, err := unityipc.NewClient(connection, version).Send( + probeContext, + debugBreakStatusCommandName, + map[string]any{"Id": id}, + ) + if err != nil { + return debugBreakStatusResponse{}, err + } + + response := debugBreakStatusResponse{} + if err := json.Unmarshal(result, &response); err != nil { + return debugBreakStatusResponse{}, err + } + return response, nil +} + +func clearDebugBreakStatusFromUnity( + ctx context.Context, + connection unityipc.Connection, + id string, +) (debugBreakStatusResponse, error) { + probeContext, cancel := context.WithTimeout(ctx, debugBreakStatusProbeTimeout) + defer cancel() + + result, err := unityipc.NewClient(connection, version).Send( + probeContext, + debugBreakClearStatusCommandName, + map[string]any{"Id": id}, + ) + if err != nil { + return debugBreakStatusResponse{}, err + } + + response := debugBreakStatusResponse{} + if err := json.Unmarshal(result, &response); err != nil { + return debugBreakStatusResponse{}, err + } + return response, nil +} + +func clearDebugBreakAfterWaitTimeout(ctx context.Context, connection unityipc.Connection, id string) { + clearContext, cancel := context.WithTimeout(ctx, debugBreakStatusProbeTimeout) + defer cancel() + _, _ = clearDebugBreakStatus(clearContext, connection, id) +} + +func debugBreakWaitError( + projectRoot string, + options waitForDebugBreakOptions, + response debugBreakStatusResponse, + state debugBreakWaitState, +) cliError { + switch state { + case debugBreakWaitStateNotArmed: + return debugBreakStateError( + errorCodeDebugBreakNotArmed, + "Debug break is not armed.", + projectRoot, + options, + response, + false) + case debugBreakWaitStateExpired: + return debugBreakStateError( + errorCodeDebugBreakExpired, + "Debug break expired before it was hit.", + projectRoot, + options, + response, + true) + case debugBreakWaitStateCleared: + return debugBreakStateError( + errorCodeDebugBreakCleared, + "Debug break was cleared before it was hit.", + projectRoot, + options, + response, + true) + default: + return debugBreakStateError( + errorCodeDebugBreakWaitTimeout, + fmt.Sprintf("Debug break was not hit within %ds.", options.timeoutSeconds), + projectRoot, + options, + response, + true) + } +} + +func debugBreakStateError( + errorCode string, + message string, + projectRoot string, + options waitForDebugBreakOptions, + response debugBreakStatusResponse, + retryable bool, +) cliError { + return cliError{ + ErrorCode: errorCode, + Phase: errorPhaseResponseWaiting, + Message: message, + Retryable: retryable, + SafeToRetry: retryable, + ProjectRoot: projectRoot, + Command: debugBreakWaitCommandName, + NextActions: []string{ + "Run `uloop arm-debug-break --id ` before waiting.", + "Confirm the code path calls `UnityCliLoopDebug.Break(\"\")` with the same id.", + }, + Details: map[string]any{ + "id": options.id, + "status": response.Status, + "hitCount": response.HitCount, + "timeoutSeconds": options.timeoutSeconds, + "elapsedMilliseconds": response.ElapsedMilliseconds, + }, + } +} diff --git a/cli/internal/cli/debug_break_wait_test.go b/cli/internal/cli/debug_break_wait_test.go new file mode 100644 index 0000000000..4ea8bba337 --- /dev/null +++ b/cli/internal/cli/debug_break_wait_test.go @@ -0,0 +1,150 @@ +package cli + +import ( + "bytes" + "context" + "strings" + "testing" + "time" + + "github.com/hatayama/unity-cli-loop/cli/internal/unityipc" +) + +// Verifies wait-for-debug-break polls until Unity reports the marker hit. +func TestWaitForDebugBreakReturnsHitAfterArmedStatus(t *testing.T) { + originalQuery := queryDebugBreakStatus + originalPoll := debugBreakStatusPoll + debugBreakStatusPoll = time.Millisecond + defer func() { + queryDebugBreakStatus = originalQuery + debugBreakStatusPoll = originalPoll + }() + + responses := []debugBreakStatusResponse{ + {Id: "jump", Status: debugBreakStatusArmed, IsArmed: true}, + {Id: "jump", Status: debugBreakStatusHit, IsHit: true, IsPaused: true, HitCount: 1}, + } + requestCount := 0 + queryDebugBreakStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (debugBreakStatusResponse, error) { + if id != "jump" { + t.Fatalf("id mismatch: %s", id) + } + response := responses[requestCount] + requestCount++ + return response, nil + } + + response, state, err := waitForDebugBreak(context.Background(), unityipc.Connection{}, waitForDebugBreakOptions{ + id: "jump", + timeoutSeconds: 1, + timeout: time.Second, + }) + if err != nil { + t.Fatalf("waitForDebugBreak failed: %v", err) + } + if state != debugBreakWaitStateHit { + t.Fatalf("state mismatch: %s", state) + } + if response.Status != debugBreakStatusHit || response.HitCount != 1 { + t.Fatalf("response mismatch: %#v", response) + } + if requestCount != 2 { + t.Fatalf("request count mismatch: %d", requestCount) + } +} + +// Verifies wait-for-debug-break clears the arm after its own timeout. +func TestRunWaitForDebugBreakClearsArmAfterTimeout(t *testing.T) { + originalQuery := queryDebugBreakStatus + originalClear := clearDebugBreakStatus + originalPoll := debugBreakStatusPoll + debugBreakStatusPoll = time.Millisecond + defer func() { + queryDebugBreakStatus = originalQuery + clearDebugBreakStatus = originalClear + debugBreakStatusPoll = originalPoll + }() + + queryDebugBreakStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (debugBreakStatusResponse, error) { + return debugBreakStatusResponse{Id: id, Status: debugBreakStatusArmed, IsArmed: true}, nil + } + + clearedID := "" + clearDebugBreakStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (debugBreakStatusResponse, error) { + clearedID = id + return debugBreakStatusResponse{Id: id, Status: debugBreakStatusCleared}, nil + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runWaitForDebugBreak(context.Background(), unityipc.Connection{}, waitForDebugBreakOptions{ + id: "jump", + timeoutSeconds: 1, + timeout: 5 * time.Millisecond, + }, &stdout, &stderr) + + if code != 1 { + t.Fatalf("expected failure, got %d with stdout %s", code, stdout.String()) + } + if clearedID != "jump" { + t.Fatalf("cleared id mismatch: %s", clearedID) + } + if !strings.Contains(stderr.String(), errorCodeDebugBreakWaitTimeout) { + t.Fatalf("timeout error missing from stderr: %s", stderr.String()) + } +} + +// Verifies wait-for-debug-break rejects calls before the marker is armed. +func TestWaitForDebugBreakReturnsNotArmedStateImmediately(t *testing.T) { + originalQuery := queryDebugBreakStatus + defer func() { + queryDebugBreakStatus = originalQuery + }() + + queryDebugBreakStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (debugBreakStatusResponse, error) { + return debugBreakStatusResponse{Id: id, Status: debugBreakStatusNotArmed}, nil + } + + response, state, err := waitForDebugBreak(context.Background(), unityipc.Connection{}, waitForDebugBreakOptions{ + id: "jump", + timeoutSeconds: 1, + timeout: time.Second, + }) + if err != nil { + t.Fatalf("waitForDebugBreak failed: %v", err) + } + if state != debugBreakWaitStateNotArmed { + t.Fatalf("state mismatch: %s", state) + } + if response.Status != debugBreakStatusNotArmed { + t.Fatalf("response mismatch: %#v", response) + } +} + +// Verifies wait-for-debug-break requires a marker id. +func TestParseWaitForDebugBreakOptionsRequiresID(t *testing.T) { + _, err := parseWaitForDebugBreakOptions([]string{"--timeout-seconds", "1"}) + + if err == nil { + t.Fatal("expected missing id error") + } + if !strings.Contains(err.Error(), "Missing required option") { + t.Fatalf("error mismatch: %v", err) + } +} diff --git a/cli/internal/cli/error_envelope.go b/cli/internal/cli/error_envelope.go index c36a5a7d2d..b9bafaea41 100644 --- a/cli/internal/cli/error_envelope.go +++ b/cli/internal/cli/error_envelope.go @@ -23,10 +23,10 @@ const ( errorCodeCLIUpdateRequired = "CLI_UPDATE_REQUIRED" errorCodeCompileWaitTimeout = "COMPILE_WAIT_TIMEOUT" errorCodeControlPlayModeWaitTimeout = "CONTROL_PLAY_MODE_WAIT_TIMEOUT" - errorCodePausePointNotArmed = "PAUSE_POINT_NOT_ARMED" - errorCodePausePointWaitTimeout = "PAUSE_POINT_WAIT_TIMEOUT" - errorCodePausePointExpired = "PAUSE_POINT_EXPIRED" - errorCodePausePointCleared = "PAUSE_POINT_CLEARED" + errorCodeDebugBreakNotArmed = "DEBUG_BREAK_NOT_ARMED" + errorCodeDebugBreakWaitTimeout = "DEBUG_BREAK_WAIT_TIMEOUT" + errorCodeDebugBreakExpired = "DEBUG_BREAK_EXPIRED" + errorCodeDebugBreakCleared = "DEBUG_BREAK_CLEARED" errorCodeInternalError = "INTERNAL_ERROR" errorPhaseArgumentParsing = "argument_parsing" diff --git a/cli/internal/cli/error_envelope_test.go b/cli/internal/cli/error_envelope_test.go index 78ee51b9a5..33ffb2dc78 100644 --- a/cli/internal/cli/error_envelope_test.go +++ b/cli/internal/cli/error_envelope_test.go @@ -437,7 +437,7 @@ func TestClassifyConnectionAttemptUsesContextProjectRootFallback(t *testing.T) { func TestAvailableCommandNamesIncludesBuiltIns(t *testing.T) { names := availableCommandNames(toolsCache{}) - expectedBuiltIns := []string{"launch", "list", "sync", "focus-window", "wait-for-pause-point", "skills", "completion", "install", "update"} + expectedBuiltIns := []string{"launch", "list", "sync", "focus-window", "wait-for-debug-break", "skills", "completion", "install", "update"} for index, expected := range expectedBuiltIns { if names[index] != expected { t.Fatalf("built-in command mismatch: %#v", names) diff --git a/cli/internal/cli/pause_point_wait.go b/cli/internal/cli/pause_point_wait.go deleted file mode 100644 index 7ea6b3b504..0000000000 --- a/cli/internal/cli/pause_point_wait.go +++ /dev/null @@ -1,364 +0,0 @@ -package cli - -import ( - "context" - "encoding/json" - "fmt" - "io" - "strconv" - "time" - - "github.com/hatayama/unity-cli-loop/cli/internal/unityipc" -) - -const ( - pausePointWaitCommandName = "wait-for-pause-point" - pausePointStatusCommandName = "get-pause-point-status" - pausePointClearStatusCommandName = "clear-pause-point-status" - pausePointIDFlagName = "id" - pausePointTimeoutFlagName = "timeout-seconds" - pausePointDefaultTimeoutSeconds = 30 - pausePointStatusProbeTimeout = 5 * time.Second - pausePointStatusArmed = "Armed" - pausePointStatusHit = "Hit" - pausePointStatusNotArmed = "NotArmed" - pausePointStatusExpired = "Expired" - pausePointStatusCleared = "Cleared" -) - -var ( - pausePointStatusPoll = 50 * time.Millisecond - queryPausePointStatus = queryPausePointStatusFromUnity - clearPausePointStatus = clearPausePointStatusFromUnity -) - -type waitForPausePointOptions struct { - id string - timeoutSeconds int - timeout time.Duration -} - -type pausePointStatusResponse struct { - Id string `json:"Id"` - Status string `json:"Status"` - IsArmed bool `json:"IsArmed"` - IsHit bool `json:"IsHit"` - HitCount int `json:"HitCount"` - TimeoutSeconds int `json:"TimeoutSeconds"` - ElapsedMilliseconds int64 `json:"ElapsedMilliseconds"` - IsPlaying bool `json:"IsPlaying"` - IsPaused bool `json:"IsPaused"` - Message string `json:"Message"` -} - -type pausePointWaitState string - -const ( - pausePointWaitStateHit pausePointWaitState = "hit" - pausePointWaitStateTimeout pausePointWaitState = "timeout" - pausePointWaitStateNotArmed pausePointWaitState = "not_armed" - pausePointWaitStateExpired pausePointWaitState = "expired" - pausePointWaitStateCleared pausePointWaitState = "cleared" -) - -func runWaitForPausePointCommand( - ctx context.Context, - connection unityipc.Connection, - args []string, - stdout io.Writer, - stderr io.Writer, -) int { - options, err := parseWaitForPausePointOptions(args) - if err != nil { - writeClassifiedError(stderr, err, errorContext{ - projectRoot: connection.ProjectRoot, - command: pausePointWaitCommandName, - }) - return 1 - } - - return runWaitForPausePoint(ctx, connection, options, stdout, stderr) -} - -func runWaitForPausePoint( - ctx context.Context, - connection unityipc.Connection, - options waitForPausePointOptions, - stdout io.Writer, - stderr io.Writer, -) int { - startedAt := time.Now() - spinner := newToolSpinner(stderr, pausePointWaitCommandName) - response, state, err := waitForPausePoint(ctx, connection, options) - spinner.Stop() - if err != nil { - writeClassifiedError(stderr, err, errorContext{ - projectRoot: connection.ProjectRoot, - command: pausePointWaitCommandName, - }) - return 1 - } - - if state == pausePointWaitStateHit { - result, marshalErr := json.Marshal(response) - if marshalErr != nil { - writeClassifiedError(stderr, marshalErr, errorContext{ - projectRoot: connection.ProjectRoot, - command: pausePointWaitCommandName, - }) - return 1 - } - writeJSON(stdout, result) - writeDebugTiming(stderr, pausePointWaitCommandName, time.Since(startedAt), unityipc.UnitySendOutcome{}) - return 0 - } - - if state == pausePointWaitStateTimeout { - clearPausePointAfterWaitTimeout(ctx, connection, options.id) - } - - writeErrorEnvelope(stderr, pausePointWaitError(connection.ProjectRoot, options, response, state)) - return 1 -} - -func parseWaitForPausePointOptions(args []string) (waitForPausePointOptions, error) { - options := waitForPausePointOptions{ - timeoutSeconds: pausePointDefaultTimeoutSeconds, - timeout: time.Duration(pausePointDefaultTimeoutSeconds) * time.Second, - } - - for index := 0; index < len(args); index++ { - arg := args[index] - name, value, consumedNext, err := parseFlagValue(arg, args, index) - if err != nil { - return waitForPausePointOptions{}, err - } - - switch name { - case pausePointIDFlagName: - options.id = value - case pausePointTimeoutFlagName: - timeoutSeconds, parseErr := parsePausePointTimeoutSeconds(value) - if parseErr != nil { - return waitForPausePointOptions{}, parseErr - } - options.timeoutSeconds = timeoutSeconds - options.timeout = time.Duration(timeoutSeconds) * time.Second - default: - return waitForPausePointOptions{}, &argumentError{ - message: "Unknown option for wait-for-pause-point: --" + name, - option: "--" + name, - command: pausePointWaitCommandName, - nextActions: []string{"Run `uloop wait-for-pause-point --help` to inspect supported options."}, - } - } - - if consumedNext { - index++ - } - } - - if options.id == "" { - return waitForPausePointOptions{}, &argumentError{ - message: "Missing required option: --id", - option: "--" + pausePointIDFlagName, - expectedType: "value", - command: pausePointWaitCommandName, - nextActions: []string{"Pass `--id ` matching UnityCliLoopDebug.Break(\"\")."}, - } - } - - return options, nil -} - -func parsePausePointTimeoutSeconds(value string) (int, error) { - timeoutSeconds, err := strconv.Atoi(value) - if err != nil || timeoutSeconds <= 0 { - return 0, invalidValueArgumentError("--"+pausePointTimeoutFlagName, value, "positive integer") - } - return timeoutSeconds, nil -} - -func waitForPausePoint( - ctx context.Context, - connection unityipc.Connection, - options waitForPausePointOptions, -) (pausePointStatusResponse, pausePointWaitState, error) { - waitContext, cancel := context.WithTimeout(ctx, options.timeout) - defer cancel() - - lastResponse := pausePointStatusResponse{Id: options.id} - var lastErr error - hasResponse := false - for { - response, err := queryPausePointStatus(waitContext, connection, options.id) - if err == nil { - lastResponse = response - hasResponse = true - state := pausePointWaitStateForStatus(response.Status) - if state != "" { - return response, state, nil - } - } else { - lastErr = err - } - - select { - case <-waitContext.Done(): - if ctx.Err() != nil { - return lastResponse, "", ctx.Err() - } - if hasResponse { - return lastResponse, pausePointWaitStateTimeout, nil - } - if lastErr != nil { - return lastResponse, "", fmt.Errorf("timed out waiting for pause point status: %w", lastErr) - } - return lastResponse, pausePointWaitStateTimeout, nil - case <-time.After(pausePointStatusPoll): - } - } -} - -func pausePointWaitStateForStatus(status string) pausePointWaitState { - switch status { - case pausePointStatusHit: - return pausePointWaitStateHit - case pausePointStatusNotArmed: - return pausePointWaitStateNotArmed - case pausePointStatusExpired: - return pausePointWaitStateExpired - case pausePointStatusCleared: - return pausePointWaitStateCleared - case pausePointStatusArmed: - return "" - default: - return "" - } -} - -func queryPausePointStatusFromUnity( - ctx context.Context, - connection unityipc.Connection, - id string, -) (pausePointStatusResponse, error) { - probeContext, cancel := context.WithTimeout(ctx, pausePointStatusProbeTimeout) - defer cancel() - - result, err := unityipc.NewClient(connection, version).Send( - probeContext, - pausePointStatusCommandName, - map[string]any{"Id": id}, - ) - if err != nil { - return pausePointStatusResponse{}, err - } - - response := pausePointStatusResponse{} - if err := json.Unmarshal(result, &response); err != nil { - return pausePointStatusResponse{}, err - } - return response, nil -} - -func clearPausePointStatusFromUnity( - ctx context.Context, - connection unityipc.Connection, - id string, -) (pausePointStatusResponse, error) { - probeContext, cancel := context.WithTimeout(ctx, pausePointStatusProbeTimeout) - defer cancel() - - result, err := unityipc.NewClient(connection, version).Send( - probeContext, - pausePointClearStatusCommandName, - map[string]any{"Id": id}, - ) - if err != nil { - return pausePointStatusResponse{}, err - } - - response := pausePointStatusResponse{} - if err := json.Unmarshal(result, &response); err != nil { - return pausePointStatusResponse{}, err - } - return response, nil -} - -func clearPausePointAfterWaitTimeout(ctx context.Context, connection unityipc.Connection, id string) { - clearContext, cancel := context.WithTimeout(ctx, pausePointStatusProbeTimeout) - defer cancel() - _, _ = clearPausePointStatus(clearContext, connection, id) -} - -func pausePointWaitError( - projectRoot string, - options waitForPausePointOptions, - response pausePointStatusResponse, - state pausePointWaitState, -) cliError { - switch state { - case pausePointWaitStateNotArmed: - return pausePointStateError( - errorCodePausePointNotArmed, - "Pause point is not armed.", - projectRoot, - options, - response, - false) - case pausePointWaitStateExpired: - return pausePointStateError( - errorCodePausePointExpired, - "Pause point expired before it was hit.", - projectRoot, - options, - response, - true) - case pausePointWaitStateCleared: - return pausePointStateError( - errorCodePausePointCleared, - "Pause point was cleared before it was hit.", - projectRoot, - options, - response, - true) - default: - return pausePointStateError( - errorCodePausePointWaitTimeout, - fmt.Sprintf("Pause point was not hit within %ds.", options.timeoutSeconds), - projectRoot, - options, - response, - true) - } -} - -func pausePointStateError( - errorCode string, - message string, - projectRoot string, - options waitForPausePointOptions, - response pausePointStatusResponse, - retryable bool, -) cliError { - return cliError{ - ErrorCode: errorCode, - Phase: errorPhaseResponseWaiting, - Message: message, - Retryable: retryable, - SafeToRetry: retryable, - ProjectRoot: projectRoot, - Command: pausePointWaitCommandName, - NextActions: []string{ - "Run `uloop arm-pause-point --id ` before waiting.", - "Confirm the code path calls `UnityCliLoopDebug.Break(\"\")` with the same id.", - }, - Details: map[string]any{ - "id": options.id, - "status": response.Status, - "hitCount": response.HitCount, - "timeoutSeconds": options.timeoutSeconds, - "elapsedMilliseconds": response.ElapsedMilliseconds, - }, - } -} diff --git a/cli/internal/cli/pause_point_wait_test.go b/cli/internal/cli/pause_point_wait_test.go deleted file mode 100644 index 94dd3bb47b..0000000000 --- a/cli/internal/cli/pause_point_wait_test.go +++ /dev/null @@ -1,150 +0,0 @@ -package cli - -import ( - "bytes" - "context" - "strings" - "testing" - "time" - - "github.com/hatayama/unity-cli-loop/cli/internal/unityipc" -) - -// Verifies wait-for-pause-point polls until Unity reports the marker hit. -func TestWaitForPausePointReturnsHitAfterArmedStatus(t *testing.T) { - originalQuery := queryPausePointStatus - originalPoll := pausePointStatusPoll - pausePointStatusPoll = time.Millisecond - defer func() { - queryPausePointStatus = originalQuery - pausePointStatusPoll = originalPoll - }() - - responses := []pausePointStatusResponse{ - {Id: "jump", Status: pausePointStatusArmed, IsArmed: true}, - {Id: "jump", Status: pausePointStatusHit, IsHit: true, IsPaused: true, HitCount: 1}, - } - requestCount := 0 - queryPausePointStatus = func( - ctx context.Context, - connection unityipc.Connection, - id string, - ) (pausePointStatusResponse, error) { - if id != "jump" { - t.Fatalf("id mismatch: %s", id) - } - response := responses[requestCount] - requestCount++ - return response, nil - } - - response, state, err := waitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ - id: "jump", - timeoutSeconds: 1, - timeout: time.Second, - }) - if err != nil { - t.Fatalf("waitForPausePoint failed: %v", err) - } - if state != pausePointWaitStateHit { - t.Fatalf("state mismatch: %s", state) - } - if response.Status != pausePointStatusHit || response.HitCount != 1 { - t.Fatalf("response mismatch: %#v", response) - } - if requestCount != 2 { - t.Fatalf("request count mismatch: %d", requestCount) - } -} - -// Verifies wait-for-pause-point clears the arm after its own timeout. -func TestRunWaitForPausePointClearsArmAfterTimeout(t *testing.T) { - originalQuery := queryPausePointStatus - originalClear := clearPausePointStatus - originalPoll := pausePointStatusPoll - pausePointStatusPoll = time.Millisecond - defer func() { - queryPausePointStatus = originalQuery - clearPausePointStatus = originalClear - pausePointStatusPoll = originalPoll - }() - - queryPausePointStatus = func( - ctx context.Context, - connection unityipc.Connection, - id string, - ) (pausePointStatusResponse, error) { - return pausePointStatusResponse{Id: id, Status: pausePointStatusArmed, IsArmed: true}, nil - } - - clearedID := "" - clearPausePointStatus = func( - ctx context.Context, - connection unityipc.Connection, - id string, - ) (pausePointStatusResponse, error) { - clearedID = id - return pausePointStatusResponse{Id: id, Status: pausePointStatusCleared}, nil - } - - var stdout bytes.Buffer - var stderr bytes.Buffer - code := runWaitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ - id: "jump", - timeoutSeconds: 1, - timeout: 5 * time.Millisecond, - }, &stdout, &stderr) - - if code != 1 { - t.Fatalf("expected failure, got %d with stdout %s", code, stdout.String()) - } - if clearedID != "jump" { - t.Fatalf("cleared id mismatch: %s", clearedID) - } - if !strings.Contains(stderr.String(), errorCodePausePointWaitTimeout) { - t.Fatalf("timeout error missing from stderr: %s", stderr.String()) - } -} - -// Verifies wait-for-pause-point rejects calls before the marker is armed. -func TestWaitForPausePointReturnsNotArmedStateImmediately(t *testing.T) { - originalQuery := queryPausePointStatus - defer func() { - queryPausePointStatus = originalQuery - }() - - queryPausePointStatus = func( - ctx context.Context, - connection unityipc.Connection, - id string, - ) (pausePointStatusResponse, error) { - return pausePointStatusResponse{Id: id, Status: pausePointStatusNotArmed}, nil - } - - response, state, err := waitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ - id: "jump", - timeoutSeconds: 1, - timeout: time.Second, - }) - if err != nil { - t.Fatalf("waitForPausePoint failed: %v", err) - } - if state != pausePointWaitStateNotArmed { - t.Fatalf("state mismatch: %s", state) - } - if response.Status != pausePointStatusNotArmed { - t.Fatalf("response mismatch: %#v", response) - } -} - -// Verifies wait-for-pause-point requires a marker id. -func TestParseWaitForPausePointOptionsRequiresID(t *testing.T) { - _, err := parseWaitForPausePointOptions([]string{"--timeout-seconds", "1"}) - - if err == nil { - t.Fatal("expected missing id error") - } - if !strings.Contains(err.Error(), "Missing required option") { - t.Fatalf("error mismatch: %v", err) - } -} diff --git a/cli/internal/cli/run.go b/cli/internal/cli/run.go index 15fbb87b72..c9325ff1fc 100644 --- a/cli/internal/cli/run.go +++ b/cli/internal/cli/run.go @@ -86,8 +86,8 @@ func RunProjectLocal(ctx context.Context, args []string, stdout io.Writer, stder return runSync(ctx, connection, stdout, stderr) case "focus-window": return runFocusWindow(ctx, connection.ProjectRoot, stdout, stderr) - case pausePointWaitCommandName: - return runWaitForPausePointCommand(ctx, connection, commandArgs, stdout, stderr) + case debugBreakWaitCommandName: + return runWaitForDebugBreakCommand(ctx, connection, commandArgs, stdout, stderr) default: tool, cache, ok, err := findToolForCommand(connection.ProjectRoot, command) if err != nil { diff --git a/cli/internal/tools/default-tools.json b/cli/internal/tools/default-tools.json index 08dcbe0553..4c178f8fe6 100644 --- a/cli/internal/tools/default-tools.json +++ b/cli/internal/tools/default-tools.json @@ -329,14 +329,14 @@ } }, { - "name": "arm-pause-point", + "name": "arm-debug-break", "description": "Arm a named UnityCliLoopDebug.Break marker so Unity pauses when that code path is reached", "inputSchema": { "type": "object", "properties": { "Id": { "type": "string", - "description": "Named pause point id passed to UnityCliLoopDebug.Break" + "description": "Named debug break id passed to UnityCliLoopDebug.Break" }, "TimeoutSeconds": { "type": "integer", @@ -347,18 +347,18 @@ } }, { - "name": "clear-pause-point", + "name": "clear-debug-break", "description": "Clear one or all named UnityCliLoopDebug.Break markers", "inputSchema": { "type": "object", "properties": { "Id": { "type": "string", - "description": "Named pause point id to clear" + "description": "Named debug break id to clear" }, "All": { "type": "boolean", - "description": "Clear every active pause point marker" + "description": "Clear every active debug break marker" } } } From d0fd38a4419c9f1f3b7106b4ccc0ed0d9cf7b8b9 Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 3 Jun 2026 16:53:43 +0900 Subject: [PATCH 06/26] Rename debug break arm command to enable Use enable-debug-break for the user-facing CLI command so the action reads as enabling a UnityCliLoopDebug.Break marker. Keep the internal armed status contract while aligning help text, tool metadata, and skill guidance with the clearer command name. --- .../Editor/UnityCliLoopToolRegistryTests.cs | 10 +++++----- .../CliOnlyTools~/PausePoint/Skill/SKILL.md | 4 ++-- .../PausePoint/PausePointTools.cs | 16 ++++++++-------- .../SimulateKeyboard/Skill/SKILL.md | 2 +- .../SimulateMouseInput/Skill/SKILL.md | 2 +- .../ToolContracts/UnityCliLoopConstants.cs | 2 +- .../PausePoints/UloopPausePointRegistry.cs | 4 ++-- cli/internal/cli/debug_break_wait.go | 4 ++-- cli/internal/tools/default-tools.json | 6 +++--- 9 files changed, 25 insertions(+), 25 deletions(-) diff --git a/Assets/Tests/Editor/UnityCliLoopToolRegistryTests.cs b/Assets/Tests/Editor/UnityCliLoopToolRegistryTests.cs index 652dfc9640..a52d6ff556 100644 --- a/Assets/Tests/Editor/UnityCliLoopToolRegistryTests.cs +++ b/Assets/Tests/Editor/UnityCliLoopToolRegistryTests.cs @@ -54,7 +54,7 @@ public void Constructor_WhenFirstPartyToolsUseToolAttribute_RegistersThem() Assert.That(registry.IsToolRegistered("compile"), Is.True); Assert.That(registry.IsToolRegistered("get-logs"), Is.True); - Assert.That(registry.IsToolRegistered(UnityCliLoopConstants.TOOL_NAME_ARM_DEBUG_BREAK), Is.True); + Assert.That(registry.IsToolRegistered(UnityCliLoopConstants.TOOL_NAME_ENABLE_DEBUG_BREAK), Is.True); Assert.That(registry.IsToolRegistered(UnityCliLoopConstants.TOOL_NAME_CLEAR_DEBUG_BREAK), Is.True); Assert.That(registry.IsToolRegistered("execute-dynamic-code"), Is.True); Assert.That(registry.IsToolRegistered("clear-console"), Is.True); @@ -257,14 +257,14 @@ public void GetToolType_WhenPausePointComesFromFirstPartyToolsAssembly_ReturnsBu // Tests that pause point tools are bundled plugins instead of application-layer tools. UnityCliLoopToolRegistry registry = ToolRegistryTestFactory.Create(); - System.Type armToolType = registry.GetToolType(UnityCliLoopConstants.TOOL_NAME_ARM_DEBUG_BREAK); + System.Type enableToolType = registry.GetToolType(UnityCliLoopConstants.TOOL_NAME_ENABLE_DEBUG_BREAK); System.Type clearToolType = registry.GetToolType(UnityCliLoopConstants.TOOL_NAME_CLEAR_DEBUG_BREAK); - Assert.That(armToolType, Is.Not.Null); + Assert.That(enableToolType, Is.Not.Null); Assert.That(clearToolType, Is.Not.Null); - Assert.That(armToolType.Assembly.GetName().Name, Is.EqualTo(PausePointAssemblyName)); + Assert.That(enableToolType.Assembly.GetName().Name, Is.EqualTo(PausePointAssemblyName)); Assert.That(clearToolType.Assembly.GetName().Name, Is.EqualTo(PausePointAssemblyName)); - AssertThirdPartyTool(armToolType, false); + AssertThirdPartyTool(enableToolType, false); AssertThirdPartyTool(clearToolType, false); } diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md index 6045ac1ad5..07b2af6300 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -24,10 +24,10 @@ UnityCliLoopDebug.Break("player-jumped"); ``` 2. Compile the project. -3. Arm the marker before triggering the code path: +3. Enable the marker before triggering the code path: ```bash -uloop arm-debug-break --id player-jumped --timeout-seconds 30 +uloop enable-debug-break --id player-jumped --timeout-seconds 30 ``` 4. Trigger the behavior, such as `simulate-keyboard`, `simulate-mouse-input`, UI interaction, or dynamic code. diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs index 413e8d47db..d2d41adc36 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs @@ -8,9 +8,9 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// - /// Parameters for arming one named debug break marker. + /// Parameters for enabling one named debug break marker. /// - public class ArmPausePointSchema : UnityCliLoopToolSchema + public class EnablePausePointSchema : UnityCliLoopToolSchema { public string Id { get; set; } = string.Empty; @@ -83,18 +83,18 @@ internal static PausePointResponse FromClearAll(UloopPausePointClearAllResult re } /// - /// Exposes debug break arming as a Unity CLI Loop tool. + /// Exposes debug break enabling as a Unity CLI Loop tool. /// [UnityCliLoopTool] - public class ArmPausePointTool : UnityCliLoopTool + public class EnablePausePointTool : UnityCliLoopTool { - public override string ToolName => UnityCliLoopConstants.TOOL_NAME_ARM_DEBUG_BREAK; + public override string ToolName => UnityCliLoopConstants.TOOL_NAME_ENABLE_DEBUG_BREAK; - protected override Task ExecuteAsync(ArmPausePointSchema parameters, CancellationToken ct) + protected override Task ExecuteAsync(EnablePausePointSchema parameters, CancellationToken ct) { ct.ThrowIfCancellationRequested(); PausePointUseCase useCase = new(); - PausePointResponse response = useCase.Arm(parameters); + PausePointResponse response = useCase.Enable(parameters); return Task.FromResult(response); } } @@ -121,7 +121,7 @@ protected override Task ExecuteAsync(ClearPausePointSchema p /// internal sealed class PausePointUseCase { - public PausePointResponse Arm(ArmPausePointSchema parameters) + public PausePointResponse Enable(EnablePausePointSchema parameters) { if (parameters == null) { diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md index d112075d26..82f4577fda 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md @@ -14,7 +14,7 @@ Simulate keyboard input on Unity PlayMode: $ARGUMENTS 1. Ensure Unity is in PlayMode (use `uloop control-play-mode --action Play` if not) 2. Execute the appropriate `uloop simulate-keyboard` command 3. Take a screenshot to verify the result: `uloop screenshot --capture-mode rendering` -4. If the screenshot cannot prove the gameplay state, place and arm a `UnityCliLoopDebug.Break("")` marker with `uloop arm-debug-break`, run the input again, then inspect while Unity is paused +4. If the screenshot cannot prove the gameplay state, place and enable a `UnityCliLoopDebug.Break("")` marker with `uloop enable-debug-break`, run the input again, then inspect while Unity is paused 5. Report what happened ## Tool Reference diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md index c259aa1ce1..b0d33c5e23 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md @@ -15,7 +15,7 @@ Simulate mouse input via Input System in Unity PlayMode: $ARGUMENTS 2. For Click/LongPress: determine the target screen position (use `uloop screenshot` to find coordinates) 3. Execute the appropriate `uloop simulate-mouse-input` command 4. Take a screenshot to verify the result: `uloop screenshot --capture-mode rendering` -5. If the screenshot cannot prove the gameplay state, place and arm a `UnityCliLoopDebug.Break("")` marker with `uloop arm-debug-break`, run the input again, then inspect while Unity is paused +5. If the screenshot cannot prove the gameplay state, place and enable a `UnityCliLoopDebug.Break("")` marker with `uloop enable-debug-break`, run the input again, then inspect while Unity is paused 6. Report what happened ## Tool Reference diff --git a/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs b/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs index aa31386424..d9c5763287 100644 --- a/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs +++ b/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs @@ -61,7 +61,7 @@ public static UnityEditor.PackageManager.PackageInfo PackageInfo // Command name constants public const string TOOL_NAME_COMPILE = "compile"; public const string TOOL_NAME_CONTROL_PLAY_MODE = "control-play-mode"; - public const string TOOL_NAME_ARM_DEBUG_BREAK = "arm-debug-break"; + public const string TOOL_NAME_ENABLE_DEBUG_BREAK = "enable-debug-break"; public const string TOOL_NAME_CLEAR_DEBUG_BREAK = "clear-debug-break"; public const string TOOL_NAME_EXECUTE_DYNAMIC_CODE = "execute-dynamic-code"; public const string TOOL_NAME_GET_HIERARCHY = "get-hierarchy"; diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs index 6240aa6db6..2ed804a1c8 100644 --- a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs +++ b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs @@ -203,7 +203,7 @@ public static UloopPausePointSnapshot NotArmed(string id, IUloopPausePointPauseC 0, pauseController.IsPlaying, pauseController.IsPaused, - "Debug break is not armed."); + "Debug break is not enabled."); } } @@ -235,7 +235,7 @@ public UloopPausePointEntry(string id, int timeoutSeconds, DateTime armedAtUtc) ExpiresAtUtc = armedAtUtc.AddSeconds(timeoutSeconds); Status = UloopPausePointStatus.Armed; IsArmed = true; - Message = "Debug break armed."; + Message = "Debug break enabled."; } public string Id { get; } diff --git a/cli/internal/cli/debug_break_wait.go b/cli/internal/cli/debug_break_wait.go index 6ca6d08a99..c5b3782608 100644 --- a/cli/internal/cli/debug_break_wait.go +++ b/cli/internal/cli/debug_break_wait.go @@ -301,7 +301,7 @@ func debugBreakWaitError( case debugBreakWaitStateNotArmed: return debugBreakStateError( errorCodeDebugBreakNotArmed, - "Debug break is not armed.", + "Debug break is not enabled.", projectRoot, options, response, @@ -350,7 +350,7 @@ func debugBreakStateError( ProjectRoot: projectRoot, Command: debugBreakWaitCommandName, NextActions: []string{ - "Run `uloop arm-debug-break --id ` before waiting.", + "Run `uloop enable-debug-break --id ` before waiting.", "Confirm the code path calls `UnityCliLoopDebug.Break(\"\")` with the same id.", }, Details: map[string]any{ diff --git a/cli/internal/tools/default-tools.json b/cli/internal/tools/default-tools.json index 4c178f8fe6..772806ed56 100644 --- a/cli/internal/tools/default-tools.json +++ b/cli/internal/tools/default-tools.json @@ -329,8 +329,8 @@ } }, { - "name": "arm-debug-break", - "description": "Arm a named UnityCliLoopDebug.Break marker so Unity pauses when that code path is reached", + "name": "enable-debug-break", + "description": "Enable a named UnityCliLoopDebug.Break marker so Unity pauses when that code path is reached", "inputSchema": { "type": "object", "properties": { @@ -340,7 +340,7 @@ }, "TimeoutSeconds": { "type": "integer", - "description": "Seconds before the arm expires and stops pausing late hits", + "description": "Seconds before the enable request expires and stops pausing late hits", "default": 30 } } From 45acfd2c80824eac2d39b47e47e0a9892a869c92 Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 3 Jun 2026 17:45:42 +0900 Subject: [PATCH 07/26] Release input simulations on debug break pause Treat a pause during keyboard or mouse input observation as a successful debug-break interruption so the tool slot is released and agents can inspect the paused Unity state instead of hitting Busy forever. --- .../Tests/PlayMode/SimulateKeyboardTests.cs | 31 +++++++ .../Tests/PlayMode/SimulateMouseInputTests.cs | 37 +++++++- .../UnityCliLoopInputSimulationTypes.cs | 2 + .../InputSystem/InputSystemUpdateHelper.cs | 64 ++++++++++++- .../SimulateKeyboardResponse.cs | 1 + .../SimulateKeyboard/SimulateKeyboardTool.cs | 1 + .../SimulateKeyboardUseCase.cs | 52 +++++++++-- .../SimulateMouseInputResponse.cs | 1 + .../SimulateMouseInputTool.cs | 1 + .../SimulateMouseInputUseCase.cs | 90 +++++++++++++++++-- 10 files changed, 263 insertions(+), 17 deletions(-) diff --git a/Assets/Tests/PlayMode/SimulateKeyboardTests.cs b/Assets/Tests/PlayMode/SimulateKeyboardTests.cs index ad1a6c170a..714f212e9a 100644 --- a/Assets/Tests/PlayMode/SimulateKeyboardTests.cs +++ b/Assets/Tests/PlayMode/SimulateKeyboardTests.cs @@ -59,6 +59,7 @@ public override void Setup() public override void TearDown() { + InputSystemUpdateHelper.ResetPauseProviderForTests(); InputSettings settings = RequireInputSettings(); settings.updateMode = originalUpdateMode; Time.timeScale = originalTimeScale; @@ -112,6 +113,36 @@ public IEnumerator Press_WithDuration_Should_HoldKey() Assert.AreEqual("Space", lastResponse.KeyName); } + [UnityTest] + public IEnumerator Press_WhenUnityPausesDuringObservation_Should_CompleteAsDebugBreakInterruption() + { + // Verifies that a debug-break pause releases the tool slot instead of leaving the press command busy. + yield return null; + + SimulateKeyboardSchema parameters = new() + { + Action = UnityCliLoopKeyboardAction.Press, + Key = "Space", + Duration = 1f + }; + Task task = + tool.ExecuteWithCancellationAsync(parameters, CancellationToken.None); + + yield return new WaitUntil(() => keyboard[Key.Space].isPressed || task.IsCompleted); + Assert.IsFalse(task.IsCompleted, "The test must pause during the press observation window."); + + InputSystemUpdateHelper.ConfigurePauseProviderForTests(() => true); + yield return WaitForTask(task); + InputSystemUpdateHelper.ResetPauseProviderForTests(); + + lastResponse = task.Result; + Assert.IsTrue(lastResponse.Success); + Assert.IsTrue(lastResponse.InterruptedByDebugBreak); + Assert.AreEqual("Press", lastResponse.Action); + Assert.AreEqual("Space", lastResponse.KeyName); + Assert.IsFalse(SimulateKeyboardOverlayState.IsActive, "Debug-break interruption should clear keyboard overlay state."); + } + [UnityTest] public IEnumerator Press_Space_Should_SetWasPressedThisFrame() { diff --git a/Assets/Tests/PlayMode/SimulateMouseInputTests.cs b/Assets/Tests/PlayMode/SimulateMouseInputTests.cs index 26c85a7867..035e4371e4 100644 --- a/Assets/Tests/PlayMode/SimulateMouseInputTests.cs +++ b/Assets/Tests/PlayMode/SimulateMouseInputTests.cs @@ -35,6 +35,7 @@ public override void Setup() public override void TearDown() { + InputSystemUpdateHelper.ResetPauseProviderForTests(); MouseInputState.ReleaseAllButtons(); Object.DestroyImmediate(mouseObserverGo); base.TearDown(); @@ -101,6 +102,35 @@ public IEnumerator Click_MiddleButton_Should_InjectMiddleClick() Assert.IsFalse(mouse.middleButton.isPressed, "Middle button should be released after click"); } + [UnityTest] + public IEnumerator Click_WhenUnityPausesDuringObservation_Should_CompleteAsDebugBreakInterruption() + { + // Verifies that a debug-break pause releases the tool slot instead of leaving the click command busy. + yield return null; + + Task task = tool.ExecuteAsync(new JObject + { + ["action"] = MouseInputAction.Click.ToString(), + ["x"] = 400, + ["y"] = 300, + ["duration"] = 1f + }, System.Threading.CancellationToken.None); + + yield return new WaitUntil(() => mouse.leftButton.isPressed || task.IsCompleted); + Assert.IsFalse(task.IsCompleted, "The test must pause during the click observation window."); + + InputSystemUpdateHelper.ConfigurePauseProviderForTests(() => true); + yield return WaitForTask(task); + InputSystemUpdateHelper.ResetPauseProviderForTests(); + + lastResponse = (SimulateMouseInputResponse)task.Result; + Assert.IsTrue(lastResponse.Success); + Assert.IsTrue(lastResponse.InterruptedByDebugBreak); + Assert.AreEqual("Click", lastResponse.Action); + Assert.AreEqual("Left", lastResponse.Button); + Assert.IsFalse(SimulateMouseInputOverlayState.HasAnyActivity, "Debug-break interruption should clear mouse overlay state."); + } + #endregion #region LongPress Tests @@ -246,12 +276,17 @@ public IEnumerator Scroll_Horizontal_Should_InjectScrollX() private IEnumerator RunTool(JObject parameters) { Task task = tool.ExecuteAsync(parameters, System.Threading.CancellationToken.None); + yield return WaitForTask(task); + lastResponse = (SimulateMouseInputResponse)task.Result; + } + + private static IEnumerator WaitForTask(Task task) + { float timeoutAt = Time.realtimeSinceStartup + 5f; yield return new WaitUntil(() => task.IsCompleted || Time.realtimeSinceStartup >= timeoutAt); Assert.IsTrue(task.IsCompleted, "Tool execution timed out."); Assert.IsFalse(task.IsFaulted, $"Tool execution should not fault: {task.Exception}"); - lastResponse = (SimulateMouseInputResponse)task.Result; } #endregion diff --git a/Packages/src/Editor/FirstPartyTools/Common/InputSimulation/UnityCliLoopInputSimulationTypes.cs b/Packages/src/Editor/FirstPartyTools/Common/InputSimulation/UnityCliLoopInputSimulationTypes.cs index 3c6a7ef704..b9d43761cd 100644 --- a/Packages/src/Editor/FirstPartyTools/Common/InputSimulation/UnityCliLoopInputSimulationTypes.cs +++ b/Packages/src/Editor/FirstPartyTools/Common/InputSimulation/UnityCliLoopInputSimulationTypes.cs @@ -95,6 +95,7 @@ public sealed class UnityCliLoopKeyboardSimulationResult public string Message { get; set; } = ""; public string Action { get; set; } = ""; public string? KeyName { get; set; } + public bool InterruptedByDebugBreak { get; set; } } /// @@ -124,6 +125,7 @@ public sealed class UnityCliLoopMouseInputSimulationResult public string? Button { get; set; } public float? PositionX { get; set; } public float? PositionY { get; set; } + public bool InterruptedByDebugBreak { get; set; } } /// diff --git a/Packages/src/Editor/FirstPartyTools/Common/InputSystem/InputSystemUpdateHelper.cs b/Packages/src/Editor/FirstPartyTools/Common/InputSystem/InputSystemUpdateHelper.cs index 8b101b1d60..bc48561f97 100644 --- a/Packages/src/Editor/FirstPartyTools/Common/InputSystem/InputSystemUpdateHelper.cs +++ b/Packages/src/Editor/FirstPartyTools/Common/InputSystem/InputSystemUpdateHelper.cs @@ -3,6 +3,7 @@ using System; using System.Threading; using System.Threading.Tasks; +using UnityEditor; using UnityEngine; using UnityEngine.InputSystem; using UnityEngine.InputSystem.LowLevel; @@ -11,6 +12,15 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { + /// + /// Reports whether input observation finished normally or stopped because Unity paused. + /// + internal enum InputSimulationWaitOutcome + { + Completed = 0, + Paused = 1 + } + // Shared helper for applying Input System state changes at the correct update phase. // Both keyboard and mouse simulation need frame-precise timing so that // wasPressedThisFrame / wasReleasedThisFrame detect the injected state. @@ -21,6 +31,7 @@ internal static class InputSystemUpdateHelper { private const int StandardPressObservationFrames = 2; private const int ManualPressObservationFrames = 3; + private static Func isPausedProvider = () => EditorApplication.isPaused; public static Task ApplyOnNextConfiguredUpdate(Action apply, CancellationToken ct) { @@ -84,12 +95,12 @@ public static int GetMinimumObservationFrameCount() return ManualPressObservationFrames; } - public static async Task WaitForObservationFrames(CancellationToken ct) + public static async Task WaitForObservationFrames(CancellationToken ct) { - await WaitForRuntimeFrames(GetMinimumObservationFrameCount(), ct); + return await WaitForRuntimeFrames(GetMinimumObservationFrameCount(), ct); } - public static async Task WaitForPressLifetime(float duration, CancellationToken ct) + public static async Task WaitForPressLifetime(float duration, CancellationToken ct) { int minimumObservationFrames = GetMinimumObservationFrameCount(); int startFrameCount = Time.frameCount; @@ -99,10 +110,22 @@ public static async Task WaitForPressLifetime(float duration, CancellationToken while (observedFrames < minimumObservationFrames || elapsed < duration) { + if (IsPaused()) + { + return InputSimulationWaitOutcome.Paused; + } + await EditorDelay.DelayFrame(1, ct); + if (IsPaused()) + { + return InputSimulationWaitOutcome.Paused; + } + observedFrames = Time.frameCount - startFrameCount; elapsed = Time.realtimeSinceStartup - startTime; } + + return InputSimulationWaitOutcome.Completed; } public static void RunExplicitUpdate(InputUpdateType targetUpdateType) @@ -203,16 +226,49 @@ private static InputSettings.UpdateMode GetExplicitUpdateMode( return fallbackUpdateMode; } - private static async Task WaitForRuntimeFrames(int frameCount, CancellationToken ct) + public static async Task WaitForRuntimeFrames(int frameCount, CancellationToken ct) { int startFrameCount = Time.frameCount; int observedFrames = 0; while (observedFrames < frameCount) { + if (IsPaused()) + { + return InputSimulationWaitOutcome.Paused; + } + await EditorDelay.DelayFrame(1, ct); + if (IsPaused()) + { + return InputSimulationWaitOutcome.Paused; + } + observedFrames = Time.frameCount - startFrameCount; } + + return InputSimulationWaitOutcome.Completed; + } + + internal static void ConfigurePauseProviderForTests(Func provider) + { + Debug.Assert(provider != null, "provider must not be null"); + if (provider == null) + { + throw new ArgumentNullException(nameof(provider)); + } + + isPausedProvider = provider; + } + + internal static void ResetPauseProviderForTests() + { + isPausedProvider = () => EditorApplication.isPaused; + } + + private static bool IsPaused() + { + return isPausedProvider(); } } } diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs index 8bfe3b4615..c1c2bfe2bc 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs @@ -13,6 +13,7 @@ public class SimulateKeyboardResponse : UnityCliLoopToolResponse public string Message { get; set; } = ""; public string Action { get; set; } = ""; public string? KeyName { get; set; } + public bool InterruptedByDebugBreak { get; set; } public SimulateKeyboardResponse() { diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardTool.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardTool.cs index 2c009ffa56..1f87f8d24b 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardTool.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardTool.cs @@ -48,6 +48,7 @@ private static SimulateKeyboardResponse ToResponse(UnityCliLoopKeyboardSimulatio Message = result.Message, Action = result.Action, KeyName = result.KeyName, + InterruptedByDebugBreak = result.InterruptedByDebugBreak, }; } } diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs index 12417750df..add902f916 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs @@ -169,12 +169,13 @@ private async Task ExecutePress( SimulateKeyboardOverlayState.ShowPress(keyName); KeyboardKeyState.RegisterTransientKey(key); bool pressWasApplied = false; + InputSimulationWaitOutcome waitOutcome = InputSimulationWaitOutcome.Completed; try { await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate(() => KeyboardKeyState.SetKeyState(keyboard, key, true), ct); pressWasApplied = true; - await InputSystemUpdateHelper.WaitForPressLifetime(duration, ct); + waitOutcome = await InputSystemUpdateHelper.WaitForPressLifetime(duration, ct); } finally { @@ -182,7 +183,14 @@ private async Task ExecutePress( { await ReleaseKeyStateIfPossible(keyboard, key); KeyboardKeyState.UnregisterTransientKey(key); - await FinalizePressOverlay(ct); + if (waitOutcome == InputSimulationWaitOutcome.Paused) + { + SimulateKeyboardOverlayState.ClearPress(); + } + else + { + await FinalizePressOverlay(ct); + } } else { @@ -191,6 +199,11 @@ private async Task ExecutePress( } } + if (waitOutcome == InputSimulationWaitOutcome.Paused) + { + return InterruptedPressResult(keyName); + } + string durationText = duration > 0f ? $" for {duration:F1}s" : ""; return new UnityCliLoopKeyboardSimulationResult { @@ -218,6 +231,7 @@ private async Task ExecuteKeyDown(Keyboard bool keyDownApplied = false; bool committed = false; + InputSimulationWaitOutcome waitOutcome = InputSimulationWaitOutcome.Completed; try { @@ -225,8 +239,8 @@ private async Task ExecuteKeyDown(Keyboard keyDownApplied = true; KeyboardKeyState.SetKeyDown(key); SimulateKeyboardOverlayState.AddHeldKey(keyName); - await InputSystemUpdateHelper.WaitForObservationFrames(ct); - committed = true; + waitOutcome = await InputSystemUpdateHelper.WaitForObservationFrames(ct); + committed = waitOutcome == InputSimulationWaitOutcome.Completed; } finally { @@ -236,6 +250,11 @@ private async Task ExecuteKeyDown(Keyboard } } + if (waitOutcome == InputSimulationWaitOutcome.Paused) + { + return InterruptedKeyResult(UnityCliLoopKeyboardAction.KeyDown, keyName); + } + return new UnityCliLoopKeyboardSimulationResult { Success = true, @@ -263,7 +282,11 @@ private async Task ExecuteKeyUp(Keyboard k await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate(() => KeyboardKeyState.SetKeyState(keyboard, key, false), ct); KeyboardKeyState.SetKeyUp(key); SimulateKeyboardOverlayState.RemoveHeldKey(keyName); - await InputSystemUpdateHelper.WaitForObservationFrames(ct); + InputSimulationWaitOutcome waitOutcome = await InputSystemUpdateHelper.WaitForObservationFrames(ct); + if (waitOutcome == InputSimulationWaitOutcome.Paused) + { + return InterruptedKeyResult(UnityCliLoopKeyboardAction.KeyUp, keyName); + } return new UnityCliLoopKeyboardSimulationResult { @@ -283,6 +306,25 @@ private static string NormalizeKeyName(string keyName) return keyName; } + private static UnityCliLoopKeyboardSimulationResult InterruptedPressResult(string keyName) + { + return InterruptedKeyResult(UnityCliLoopKeyboardAction.Press, keyName); + } + + private static UnityCliLoopKeyboardSimulationResult InterruptedKeyResult( + UnityCliLoopKeyboardAction action, + string keyName) + { + return new UnityCliLoopKeyboardSimulationResult + { + Success = true, + Message = $"Keyboard input stopped because Unity paused during Debug Break inspection. Key '{keyName}' was released from Unity CLI Loop bookkeeping.", + Action = action.ToString(), + KeyName = keyName, + InterruptedByDebugBreak = true + }; + } + private static async Task FinalizePressOverlay(CancellationToken ct) { if (ct.IsCancellationRequested) diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputResponse.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputResponse.cs index a7f3ffa646..1add057342 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputResponse.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputResponse.cs @@ -15,6 +15,7 @@ public class SimulateMouseInputResponse : UnityCliLoopToolResponse public string? Button { get; set; } public float? PositionX { get; set; } public float? PositionY { get; set; } + public bool InterruptedByDebugBreak { get; set; } public SimulateMouseInputResponse() { diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputTool.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputTool.cs index d8a5a49541..e9f1691ae7 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputTool.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputTool.cs @@ -57,6 +57,7 @@ private static SimulateMouseInputResponse ToResponse(UnityCliLoopMouseInputSimul Button = result.Button, PositionX = result.PositionX, PositionY = result.PositionY, + InterruptedByDebugBreak = result.InterruptedByDebugBreak, }; } } diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs index 80fb62410d..9064cf05a3 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs @@ -182,13 +182,14 @@ await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( MouseInputState.SetButtonDown(button); SimulateMouseInputOverlayState.SetButtonHeld(button, true); bool pressWasApplied = false; + InputSimulationWaitOutcome waitOutcome = InputSimulationWaitOutcome.Completed; try { await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( () => MouseInputState.SetButtonState(mouse, button, true), ct); pressWasApplied = true; - await InputSystemUpdateHelper.WaitForPressLifetime(request.Duration, ct); + waitOutcome = await InputSystemUpdateHelper.WaitForPressLifetime(request.Duration, ct); } finally { @@ -201,7 +202,22 @@ await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( { MouseInputState.SetButtonUp(button); } - SimulateMouseInputOverlayState.SetButtonHeld(button, false); + if (waitOutcome == InputSimulationWaitOutcome.Paused) + { + SimulateMouseInputOverlayState.Clear(); + } + else + { + SimulateMouseInputOverlayState.SetButtonHeld(button, false); + } + } + + if (waitOutcome == InputSimulationWaitOutcome.Paused) + { + return InterruptedButtonResult( + UnityCliLoopMouseInputAction.Click, + buttonName, + inputPos); } string durationText = request.Duration > 0f ? $" for {request.Duration:F1}s" : ""; @@ -242,6 +258,7 @@ await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( MouseInputState.SetButtonDown(button); SimulateMouseInputOverlayState.SetButtonHeld(button, true); bool pressWasApplied = false; + InputSimulationWaitOutcome waitOutcome = InputSimulationWaitOutcome.Completed; try { @@ -251,7 +268,7 @@ await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( // Hold for at least the minimum observation frames so the press // is visible to game code, then continue until duration elapses. - await InputSystemUpdateHelper.WaitForPressLifetime(request.Duration, ct); + waitOutcome = await InputSystemUpdateHelper.WaitForPressLifetime(request.Duration, ct); } finally { @@ -260,7 +277,22 @@ await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( await ReleaseButtonIfPossible(mouse, button); } MouseInputState.SetButtonUp(button); - SimulateMouseInputOverlayState.SetButtonHeld(button, false); + if (waitOutcome == InputSimulationWaitOutcome.Paused) + { + SimulateMouseInputOverlayState.Clear(); + } + else + { + SimulateMouseInputOverlayState.SetButtonHeld(button, false); + } + } + + if (waitOutcome == InputSimulationWaitOutcome.Paused) + { + return InterruptedButtonResult( + UnityCliLoopMouseInputAction.LongPress, + buttonName, + inputPos); } return new UnityCliLoopMouseInputSimulationResult @@ -282,7 +314,12 @@ private async Task ExecuteMoveDelta( await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( () => MouseInputState.SetDeltaState(mouse, delta), ct); - await InputSystemUpdateHelper.WaitForObservationFrames(ct); + InputSimulationWaitOutcome waitOutcome = await InputSystemUpdateHelper.WaitForObservationFrames(ct); + if (waitOutcome == InputSimulationWaitOutcome.Paused) + { + SimulateMouseInputOverlayState.Clear(); + return InterruptedActionResult(UnityCliLoopMouseInputAction.MoveDelta); + } return new UnityCliLoopMouseInputSimulationResult { @@ -302,7 +339,12 @@ private async Task ExecuteScroll( await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( () => MouseInputState.SetScrollState(mouse, scroll), ct); - await InputSystemUpdateHelper.WaitForObservationFrames(ct); + InputSimulationWaitOutcome waitOutcome = await InputSystemUpdateHelper.WaitForObservationFrames(ct); + if (waitOutcome == InputSimulationWaitOutcome.Paused) + { + SimulateMouseInputOverlayState.Clear(); + return InterruptedActionResult(UnityCliLoopMouseInputAction.Scroll); + } return new UnityCliLoopMouseInputSimulationResult { @@ -346,6 +388,11 @@ await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( () => MouseInputState.InjectDelta(mouse, frameDelta), ct); previousT = t; + if (EditorApplication.isPaused) + { + SimulateMouseInputOverlayState.Clear(); + return InterruptedActionResult(UnityCliLoopMouseInputAction.SmoothDelta); + } if (t >= 1f) { @@ -358,7 +405,12 @@ await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( // ApplyOnNextConfiguredUpdate's onBeforeUpdate callback. if (InputUpdateTypeResolver.RequiresExplicitUpdate()) { - await EditorDelay.DelayFrame(1, ct); + InputSimulationWaitOutcome waitOutcome = await InputSystemUpdateHelper.WaitForRuntimeFrames(1, ct); + if (waitOutcome == InputSimulationWaitOutcome.Paused) + { + SimulateMouseInputOverlayState.Clear(); + return InterruptedActionResult(UnityCliLoopMouseInputAction.SmoothDelta); + } } } @@ -374,6 +426,30 @@ await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( }; } + private static UnityCliLoopMouseInputSimulationResult InterruptedButtonResult( + UnityCliLoopMouseInputAction action, + string buttonName, + Vector2 inputPos) + { + UnityCliLoopMouseInputSimulationResult result = InterruptedActionResult(action); + result.Button = buttonName; + result.PositionX = inputPos.x; + result.PositionY = inputPos.y; + return result; + } + + private static UnityCliLoopMouseInputSimulationResult InterruptedActionResult( + UnityCliLoopMouseInputAction action) + { + return new UnityCliLoopMouseInputSimulationResult + { + Success = true, + Message = "Mouse input stopped because Unity paused during Debug Break inspection. Unity CLI Loop released its held input bookkeeping.", + Action = action.ToString(), + InterruptedByDebugBreak = true + }; + } + private static async Task ReleaseButtonIfPossible(Mouse mouse, RuntimeMouseButton button) { if (!CanInjectMouseState(mouse)) From 6e1f896963b0f4a31ff7f98176384838b1d7c5c0 Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 3 Jun 2026 17:53:14 +0900 Subject: [PATCH 08/26] Add debug break status and busy state metadata Expose debug-break-status as a CLI-only marker inspection command, surface PlayMode Domain Reload risk when enabling markers before Play, and include running/requested tool plus Unity play state in Busy responses so agents can diagnose paused-editor stalls. --- .../JsonRpcProcessorCliVersionGateTests.cs | 4 + Assets/Tests/Editor/PausePointTests.cs | 49 +++++++++++ .../UnityCliLoopEditorStateGuard.cs | 12 ++- .../UnityCliLoopToolBusyException.cs | 12 ++- .../UnityCliLoopToolExecutionService.cs | 7 +- .../PausePoint/PausePointTools.cs | 32 ++++++- .../Infrastructure/Api/JsonRpcProcessor.cs | 10 +++ cli/internal/cli/busy_status.go | 62 ++++++++++++-- cli/internal/cli/command_help.go | 2 +- cli/internal/cli/command_registry.go | 1 + cli/internal/cli/completion_options.go | 4 + cli/internal/cli/debug_break_wait.go | 83 +++++++++++++++++++ cli/internal/cli/debug_break_wait_test.go | 49 +++++++++++ cli/internal/cli/error_envelope.go | 2 +- cli/internal/cli/error_envelope_test.go | 22 ++++- cli/internal/cli/run.go | 2 + 16 files changed, 338 insertions(+), 15 deletions(-) diff --git a/Assets/Tests/Editor/JsonRpcProcessorCliVersionGateTests.cs b/Assets/Tests/Editor/JsonRpcProcessorCliVersionGateTests.cs index 77e2ddd1a1..d062cacbeb 100644 --- a/Assets/Tests/Editor/JsonRpcProcessorCliVersionGateTests.cs +++ b/Assets/Tests/Editor/JsonRpcProcessorCliVersionGateTests.cs @@ -121,6 +121,8 @@ public async Task ProcessRequest_WhenFirstToolWaitsForMainThread_ReturnsServerBu Assert.That(data["type"]?.ToString(), Is.EqualTo("server_busy")); Assert.That(data["runningToolName"]?.ToString(), Is.EqualTo(SingleFlightTestTool.Name)); Assert.That(data["requestedToolName"]?.ToString(), Is.EqualTo(SingleFlightTestTool.Name)); + Assert.That(data["isPlaying"]?.ToObject(), Is.EqualTo(UnityEditor.EditorApplication.isPlaying)); + Assert.That(data["isPaused"]?.ToObject(), Is.EqualTo(UnityEditor.EditorApplication.isPaused)); LogAssert.NoUnexpectedReceived(); } finally @@ -184,6 +186,8 @@ public async Task ProcessRequest_WhenExecuteDynamicCodeWaitsForMainThread_Allows Assert.That(data["type"]?.ToString(), Is.EqualTo("server_busy")); Assert.That(data["runningToolName"]?.ToString(), Is.EqualTo(UnityCliLoopConstants.TOOL_NAME_EXECUTE_DYNAMIC_CODE)); Assert.That(data["requestedToolName"]?.ToString(), Is.EqualTo(SingleFlightTestTool.Name)); + Assert.That(data["isPlaying"]?.ToObject(), Is.EqualTo(UnityEditor.EditorApplication.isPlaying)); + Assert.That(data["isPaused"]?.ToObject(), Is.EqualTo(UnityEditor.EditorApplication.isPaused)); LogAssert.NoUnexpectedReceived(); } finally diff --git a/Assets/Tests/Editor/PausePointTests.cs b/Assets/Tests/Editor/PausePointTests.cs index 2405f3818f..2d09084b57 100644 --- a/Assets/Tests/Editor/PausePointTests.cs +++ b/Assets/Tests/Editor/PausePointTests.cs @@ -1,7 +1,12 @@ using System; using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; using NUnit.Framework; +using UnityEditor; +using io.github.hatayama.UnityCliLoop.FirstPartyTools; using io.github.hatayama.UnityCliLoop.Runtime; namespace io.github.hatayama.UnityCliLoop.Tests.Editor @@ -13,12 +18,16 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor public sealed class PausePointTests { private DateTime _nowUtc; + private bool _originalEnterPlayModeOptionsEnabled; + private EnterPlayModeOptions _originalEnterPlayModeOptions; private FakePauseController _pauseController; [SetUp] public void SetUp() { _nowUtc = new DateTime(2026, 6, 3, 0, 0, 0, DateTimeKind.Utc); + _originalEnterPlayModeOptionsEnabled = EditorSettings.enterPlayModeOptionsEnabled; + _originalEnterPlayModeOptions = EditorSettings.enterPlayModeOptions; _pauseController = new FakePauseController(); UloopPausePointRegistry.ConfigureForTests(_pauseController, () => _nowUtc); } @@ -26,6 +35,8 @@ public void SetUp() [TearDown] public void TearDown() { + EditorSettings.enterPlayModeOptionsEnabled = _originalEnterPlayModeOptionsEnabled; + EditorSettings.enterPlayModeOptions = _originalEnterPlayModeOptions; UloopPausePointRegistry.ResetForTests(); } @@ -117,6 +128,44 @@ public void BreakMethod_WhenSourceIsScanned_UsesUnityEditorConditionalWithoutDeb Assert.That(source, Does.Not.Contain("Debug.Break")); } + [Test] + public async Task Enable_WhenPlayModeInactiveAndDomainReloadEnabled_ReturnsWarning() + { + // Verifies PlayMode entry risk is reported only when Domain Reload can clear the marker. + EditorSettings.enterPlayModeOptionsEnabled = false; + EditorSettings.enterPlayModeOptions = EnterPlayModeOptions.None; + + PausePointResponse response = await EnableDebugBreakAsync("jump"); + + Assert.That(response.Warning, Does.Contain("Domain Reload is enabled")); + Assert.That(response.Warning, Does.Contain("keep Domain Reload disabled")); + } + + [Test] + public async Task Enable_WhenPlayModeInactiveAndDomainReloadDisabled_ReturnsNoWarning() + { + // Verifies the normal no-domain-reload workflow does not suggest re-arming after Play starts. + EditorSettings.enterPlayModeOptionsEnabled = true; + EditorSettings.enterPlayModeOptions = EnterPlayModeOptions.DisableDomainReload; + + PausePointResponse response = await EnableDebugBreakAsync("dash"); + + Assert.That(response.Warning, Is.Empty); + } + + private static async Task EnableDebugBreakAsync(string id) + { + EnablePausePointTool tool = new(); + JObject parameters = new() + { + ["id"] = id, + ["timeoutSeconds"] = 30 + }; + + PausePointResponse response = (PausePointResponse)await tool.ExecuteAsync(parameters, CancellationToken.None); + return response; + } + /// /// Test double that records pause requests without mutating Unity Editor state. /// diff --git a/Packages/src/Editor/Application/UnityCliLoopEditorStateGuard.cs b/Packages/src/Editor/Application/UnityCliLoopEditorStateGuard.cs index ab8986464a..ee781e3c9d 100644 --- a/Packages/src/Editor/Application/UnityCliLoopEditorStateGuard.cs +++ b/Packages/src/Editor/Application/UnityCliLoopEditorStateGuard.cs @@ -42,12 +42,20 @@ internal static void ValidateForState(string toolName, bool isCompiling, bool is if ((condition & GuardCondition.NotCompiling) != 0 && isCompiling) { - throw new UnityCliLoopToolBusyException(UnityCompileOperationName, toolName); + throw new UnityCliLoopToolBusyException( + UnityCompileOperationName, + toolName, + EditorApplication.isPlaying, + EditorApplication.isPaused); } if ((condition & GuardCondition.NotUpdating) != 0 && isUpdating) { - throw new UnityCliLoopToolBusyException(UnityAssetDatabaseUpdateOperationName, toolName); + throw new UnityCliLoopToolBusyException( + UnityAssetDatabaseUpdateOperationName, + toolName, + EditorApplication.isPlaying, + EditorApplication.isPaused); } } diff --git a/Packages/src/Editor/Application/UnityCliLoopToolBusyException.cs b/Packages/src/Editor/Application/UnityCliLoopToolBusyException.cs index 9ed6de076e..9312ea81ed 100644 --- a/Packages/src/Editor/Application/UnityCliLoopToolBusyException.cs +++ b/Packages/src/Editor/Application/UnityCliLoopToolBusyException.cs @@ -7,17 +7,27 @@ namespace io.github.hatayama.UnityCliLoop.Application /// public sealed class UnityCliLoopToolBusyException : Exception { - public UnityCliLoopToolBusyException(string runningToolName, string requestedToolName) + public UnityCliLoopToolBusyException( + string runningToolName, + string requestedToolName, + bool isPlaying = false, + bool isPaused = false) : base(CreateMessage(runningToolName, requestedToolName)) { RunningToolName = runningToolName; RequestedToolName = requestedToolName; + IsPlaying = isPlaying; + IsPaused = isPaused; } public string RunningToolName { get; } public string RequestedToolName { get; } + public bool IsPlaying { get; } + + public bool IsPaused { get; } + private static string CreateMessage(string runningToolName, string requestedToolName) { return $"Unity is busy running '{runningToolName}'. Retry '{requestedToolName}' after the running tool completes."; diff --git a/Packages/src/Editor/Application/UnityCliLoopToolExecutionService.cs b/Packages/src/Editor/Application/UnityCliLoopToolExecutionService.cs index 752af16874..80ca22dba0 100644 --- a/Packages/src/Editor/Application/UnityCliLoopToolExecutionService.cs +++ b/Packages/src/Editor/Application/UnityCliLoopToolExecutionService.cs @@ -3,6 +3,7 @@ using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json.Linq; +using UnityEditor; using io.github.hatayama.UnityCliLoop.Domain; using io.github.hatayama.UnityCliLoop.ToolContracts; @@ -49,7 +50,11 @@ internal async Task ExecuteToolAsync( if (!TryEnterExecution(toolName, out string runningToolName)) { - throw new UnityCliLoopToolBusyException(runningToolName, toolName); + throw new UnityCliLoopToolBusyException( + runningToolName, + toolName, + EditorApplication.isPlaying, + EditorApplication.isPaused); } try diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs index d2d41adc36..6a46d7ce0b 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs @@ -1,6 +1,7 @@ using System; using System.Threading; using System.Threading.Tasks; +using UnityEditor; using io.github.hatayama.UnityCliLoop.Runtime; using io.github.hatayama.UnityCliLoop.ToolContracts; @@ -43,6 +44,7 @@ public class PausePointResponse : UnityCliLoopToolResponse public bool IsPaused { get; set; } public int ClearedCount { get; set; } public string Message { get; set; } = string.Empty; + public string Warning { get; set; } = string.Empty; internal static PausePointResponse FromSnapshot(UloopPausePointSnapshot snapshot) { @@ -135,7 +137,9 @@ public PausePointResponse Enable(EnablePausePointSchema parameters) } UloopPausePointSnapshot snapshot = UloopPausePointRegistry.Arm(id, parameters.TimeoutSeconds); - return PausePointResponse.FromSnapshot(snapshot); + PausePointResponse response = PausePointResponse.FromSnapshot(snapshot); + response.Warning = CreateEnableWarning(); + return response; } public PausePointResponse Clear(ClearPausePointSchema parameters) @@ -165,5 +169,31 @@ private static string RequireId(string id) return id; } + + private static string CreateEnableWarning() + { + if (EditorApplication.isPlaying) + { + return string.Empty; + } + + if (IsDomainReloadDisabledOnEnterPlayMode()) + { + return string.Empty; + } + + return "Debug break was enabled before PlayMode while Domain Reload is enabled. " + + "Entering PlayMode may clear this marker; keep Domain Reload disabled for this workflow or enable the marker after PlayMode starts."; + } + + private static bool IsDomainReloadDisabledOnEnterPlayMode() + { + if (!EditorSettings.enterPlayModeOptionsEnabled) + { + return false; + } + + return (EditorSettings.enterPlayModeOptions & EnterPlayModeOptions.DisableDomainReload) != 0; + } } } diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcProcessor.cs b/Packages/src/Editor/Infrastructure/Api/JsonRpcProcessor.cs index 8ed8d81baf..972ba6a7f8 100644 --- a/Packages/src/Editor/Infrastructure/Api/JsonRpcProcessor.cs +++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcProcessor.cs @@ -394,6 +394,8 @@ private static string CreateErrorResponse(object id, Exception ex) errorData = new ServerBusyErrorData( busyEx.RunningToolName, busyEx.RequestedToolName, + busyEx.IsPlaying, + busyEx.IsPaused, exceptionResponse.Explanation ?? ex.Message); } else @@ -501,14 +503,22 @@ public class ServerBusyErrorData : JsonRpcErrorData public string requestedToolName { get; } + public bool isPlaying { get; } + + public bool isPaused { get; } + public ServerBusyErrorData( string runningToolName, string requestedToolName, + bool isPlaying, + bool isPaused, string message) : base(message) { this.runningToolName = runningToolName; this.requestedToolName = requestedToolName; + this.isPlaying = isPlaying; + this.isPaused = isPaused; } } diff --git a/cli/internal/cli/busy_status.go b/cli/internal/cli/busy_status.go index 22e8c8e415..5f26618d20 100644 --- a/cli/internal/cli/busy_status.go +++ b/cli/internal/cli/busy_status.go @@ -9,19 +9,63 @@ import ( const cliStatusBusy = "Busy" type cliStatusEnvelope struct { - Status string `json:"Status"` - Message string `json:"Message"` + Status string `json:"Status"` + Message string `json:"Message"` + RunningToolName string `json:"RunningToolName,omitempty"` + RequestedToolName string `json:"RequestedToolName,omitempty"` + IsPlaying *bool `json:"IsPlaying,omitempty"` + IsPaused *bool `json:"IsPaused,omitempty"` } -func writeBusyStatusEnvelope(writer io.Writer, message string) { +type serverBusyStatusDetails struct { + runningToolName string + requestedToolName string + isPlaying bool + hasIsPlaying bool + isPaused bool + hasIsPaused bool +} + +func writeBusyStatusEnvelope(writer io.Writer, message string, details serverBusyStatusDetails) { encoder := json.NewEncoder(writer) encoder.SetIndent("", " ") _ = encoder.Encode(cliStatusEnvelope{ - Status: cliStatusBusy, - Message: message, + Status: cliStatusBusy, + Message: message, + RunningToolName: details.runningToolName, + RequestedToolName: details.requestedToolName, + IsPlaying: optionalBool(details.hasIsPlaying, details.isPlaying), + IsPaused: optionalBool(details.hasIsPaused, details.isPaused), }) } +func serverBusyStatusDetailsFromError(err cliError) serverBusyStatusDetails { + data, ok := err.Details["data"].(map[string]any) + if !ok { + return serverBusyStatusDetails{ + requestedToolName: err.Command, + } + } + + isPlaying, hasIsPlaying := rpcBoolData(data, "isPlaying") + isPaused, hasIsPaused := rpcBoolData(data, "isPaused") + return serverBusyStatusDetails{ + runningToolName: rpcStringData(data, "runningToolName"), + requestedToolName: firstNonEmpty(rpcStringData(data, "requestedToolName"), err.Command), + isPlaying: isPlaying, + hasIsPlaying: hasIsPlaying, + isPaused: isPaused, + hasIsPaused: hasIsPaused, + } +} + +func optionalBool(hasValue bool, value bool) *bool { + if !hasValue { + return nil + } + return &value +} + func unityServerBusyMessage(fallback string, data map[string]any, requestedCommand string) string { runningToolName := rpcStringData(data, "runningToolName") requestedToolName := firstNonEmpty(rpcStringData(data, "requestedToolName"), requestedCommand) @@ -41,3 +85,11 @@ func rpcStringData(data map[string]any, key string) string { } return value } + +func rpcBoolData(data map[string]any, key string) (bool, bool) { + value, ok := data[key].(bool) + if !ok { + return false, false + } + return value, true +} diff --git a/cli/internal/cli/command_help.go b/cli/internal/cli/command_help.go index ff69e984c5..2bbf0e997f 100644 --- a/cli/internal/cli/command_help.go +++ b/cli/internal/cli/command_help.go @@ -211,7 +211,7 @@ func nativeCommandDescription(command string) (string, bool) { func nativeCommandUsesProject(command string) bool { switch command { - case launchCommandName, "list", "sync", "focus-window", skillsCommandName, debugBreakWaitCommandName: + case launchCommandName, "list", "sync", "focus-window", skillsCommandName, debugBreakWaitCommandName, debugBreakStatusUserCommandName: return true default: return false diff --git a/cli/internal/cli/command_registry.go b/cli/internal/cli/command_registry.go index 4dd88d7449..927c756efa 100644 --- a/cli/internal/cli/command_registry.go +++ b/cli/internal/cli/command_registry.go @@ -11,6 +11,7 @@ var nativeCommands = []nativeCommandEntry{ {name: "sync", description: "Refresh .uloop/tools.json from the running Editor"}, {name: "focus-window", description: "Bring the Unity Editor window to the foreground"}, {name: "wait-for-debug-break", description: "Wait until a named UnityCliLoopDebug.Break marker pauses Unity"}, + {name: "debug-break-status", description: "Show the state of a named UnityCliLoopDebug.Break marker"}, {name: "skills", description: "List, install, or uninstall agent skills"}, {name: "completion", description: "Print or install shell completion"}, {name: "install", description: "Configure the global uloop launcher binary"}, diff --git a/cli/internal/cli/completion_options.go b/cli/internal/cli/completion_options.go index 9da0fbcd47..1fd89b3a51 100644 --- a/cli/internal/cli/completion_options.go +++ b/cli/internal/cli/completion_options.go @@ -17,4 +17,8 @@ var nativeCommandOptions = map[string][]string{ "--" + debugBreakTimeoutFlagName, "--" + projectPathFlagName, }, + debugBreakStatusUserCommandName: { + "--" + debugBreakIDFlagName, + "--" + projectPathFlagName, + }, } diff --git a/cli/internal/cli/debug_break_wait.go b/cli/internal/cli/debug_break_wait.go index c5b3782608..3451c09274 100644 --- a/cli/internal/cli/debug_break_wait.go +++ b/cli/internal/cli/debug_break_wait.go @@ -13,6 +13,7 @@ import ( const ( debugBreakWaitCommandName = "wait-for-debug-break" + debugBreakStatusUserCommandName = "debug-break-status" debugBreakStatusCommandName = "get-debug-break-status" debugBreakClearStatusCommandName = "clear-debug-break-status" debugBreakIDFlagName = "id" @@ -38,6 +39,10 @@ type waitForDebugBreakOptions struct { timeout time.Duration } +type debugBreakStatusOptions struct { + id string +} + type debugBreakStatusResponse struct { Id string `json:"Id"` Status string `json:"Status"` @@ -80,6 +85,44 @@ func runWaitForDebugBreakCommand( return runWaitForDebugBreak(ctx, connection, options, stdout, stderr) } +func runDebugBreakStatusCommand( + ctx context.Context, + connection unityipc.Connection, + args []string, + stdout io.Writer, + stderr io.Writer, +) int { + options, err := parseDebugBreakStatusOptions(args) + if err != nil { + writeClassifiedError(stderr, err, errorContext{ + projectRoot: connection.ProjectRoot, + command: debugBreakStatusUserCommandName, + }) + return 1 + } + + response, err := queryDebugBreakStatus(ctx, connection, options.id) + if err != nil { + writeClassifiedError(stderr, err, errorContext{ + projectRoot: connection.ProjectRoot, + command: debugBreakStatusUserCommandName, + }) + return 1 + } + + result, err := json.Marshal(response) + if err != nil { + writeClassifiedError(stderr, err, errorContext{ + projectRoot: connection.ProjectRoot, + command: debugBreakStatusUserCommandName, + }) + return 1 + } + + writeJSON(stdout, result) + return 0 +} + func runWaitForDebugBreak( ctx context.Context, connection unityipc.Connection, @@ -171,6 +214,46 @@ func parseWaitForDebugBreakOptions(args []string) (waitForDebugBreakOptions, err return options, nil } +func parseDebugBreakStatusOptions(args []string) (debugBreakStatusOptions, error) { + options := debugBreakStatusOptions{} + + for index := 0; index < len(args); index++ { + arg := args[index] + name, value, consumedNext, err := parseFlagValue(arg, args, index) + if err != nil { + return debugBreakStatusOptions{}, err + } + + switch name { + case debugBreakIDFlagName: + options.id = value + default: + return debugBreakStatusOptions{}, &argumentError{ + message: "Unknown option for debug-break-status: --" + name, + option: "--" + name, + command: debugBreakStatusUserCommandName, + nextActions: []string{"Run `uloop debug-break-status --help` to inspect supported options."}, + } + } + + if consumedNext { + index++ + } + } + + if options.id == "" { + return debugBreakStatusOptions{}, &argumentError{ + message: "Missing required option: --id", + option: "--" + debugBreakIDFlagName, + expectedType: "value", + command: debugBreakStatusUserCommandName, + nextActions: []string{"Pass `--id ` matching UnityCliLoopDebug.Break(\"\")."}, + } + } + + return options, nil +} + func parseDebugBreakTimeoutSeconds(value string) (int, error) { timeoutSeconds, err := strconv.Atoi(value) if err != nil || timeoutSeconds <= 0 { diff --git a/cli/internal/cli/debug_break_wait_test.go b/cli/internal/cli/debug_break_wait_test.go index 4ea8bba337..23595590bf 100644 --- a/cli/internal/cli/debug_break_wait_test.go +++ b/cli/internal/cli/debug_break_wait_test.go @@ -3,6 +3,7 @@ package cli import ( "bytes" "context" + "encoding/json" "strings" "testing" "time" @@ -148,3 +149,51 @@ func TestParseWaitForDebugBreakOptionsRequiresID(t *testing.T) { t.Fatalf("error mismatch: %v", err) } } + +// Verifies debug-break-status reports the current marker state without waiting for a hit. +func TestRunDebugBreakStatusReturnsCurrentStatus(t *testing.T) { + originalQuery := queryDebugBreakStatus + defer func() { + queryDebugBreakStatus = originalQuery + }() + + queryDebugBreakStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (debugBreakStatusResponse, error) { + return debugBreakStatusResponse{Id: id, Status: debugBreakStatusArmed, IsArmed: true}, nil + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runDebugBreakStatusCommand( + context.Background(), + unityipc.Connection{ProjectRoot: "/tmp/MyProject"}, + []string{"--id", "jump"}, + &stdout, + &stderr) + + if code != 0 { + t.Fatalf("expected success, got %d with stderr %s", code, stderr.String()) + } + var response debugBreakStatusResponse + if err := json.Unmarshal(stdout.Bytes(), &response); err != nil { + t.Fatalf("stdout is not valid JSON: %v\n%s", err, stdout.String()) + } + if response.Status != debugBreakStatusArmed { + t.Fatalf("status mismatch: %#v", response) + } +} + +// Verifies debug-break-status requires a marker id. +func TestParseDebugBreakStatusOptionsRequiresID(t *testing.T) { + _, err := parseDebugBreakStatusOptions([]string{}) + + if err == nil { + t.Fatal("expected missing id error") + } + if !strings.Contains(err.Error(), "Missing required option") { + t.Fatalf("error mismatch: %v", err) + } +} diff --git a/cli/internal/cli/error_envelope.go b/cli/internal/cli/error_envelope.go index b9bafaea41..eac5ae0fc8 100644 --- a/cli/internal/cli/error_envelope.go +++ b/cli/internal/cli/error_envelope.go @@ -67,7 +67,7 @@ type errorContext struct { func writeErrorEnvelope(writer io.Writer, err cliError) { if err.ErrorCode == errorCodeUnityServerBusy { - writeBusyStatusEnvelope(writer, err.Message) + writeBusyStatusEnvelope(writer, err.Message, serverBusyStatusDetailsFromError(err)) return } encoder := json.NewEncoder(writer) diff --git a/cli/internal/cli/error_envelope_test.go b/cli/internal/cli/error_envelope_test.go index 33ffb2dc78..2136ea5b30 100644 --- a/cli/internal/cli/error_envelope_test.go +++ b/cli/internal/cli/error_envelope_test.go @@ -218,7 +218,7 @@ func TestClassifyServerBusyRPCError(t *testing.T) { Code: -32603, Message: "Unity is busy running 'compile'. Retry 'get-logs' after the running tool completes.", Data: json.RawMessage( - `{"type":"server_busy","runningToolName":"compile","requestedToolName":"get-logs","message":"Unity is busy running 'compile'. Retry 'get-logs' after the running tool completes."}`), + `{"type":"server_busy","runningToolName":"compile","requestedToolName":"get-logs","isPlaying":true,"isPaused":true,"message":"Unity is busy running 'compile'. Retry 'get-logs' after the running tool completes."}`), } cliErr := classifyError(err, errorContext{projectRoot: "/tmp/MyProject", command: "get-logs"}) @@ -235,6 +235,13 @@ func TestClassifyServerBusyRPCError(t *testing.T) { if cliErr.Message != expectedMessage { t.Fatalf("message mismatch: %s", cliErr.Message) } + data, ok := cliErr.Details["data"].(map[string]any) + if !ok { + t.Fatalf("busy data missing: %#v", cliErr.Details) + } + if data["isPlaying"] != true || data["isPaused"] != true { + t.Fatalf("play state mismatch: %#v", data) + } } func TestWriteClassifiedServerBusyRPCErrorWritesBusyStatus(t *testing.T) { @@ -243,7 +250,7 @@ func TestWriteClassifiedServerBusyRPCErrorWritesBusyStatus(t *testing.T) { Code: -32603, Message: "Unity is busy running 'compile'. Retry 'get-logs' after the running tool completes.", Data: json.RawMessage( - `{"type":"server_busy","runningToolName":"compile","requestedToolName":"get-logs","message":"Unity is busy running 'compile'. Retry 'get-logs' after the running tool completes."}`), + `{"type":"server_busy","runningToolName":"compile","requestedToolName":"get-logs","isPlaying":true,"isPaused":true,"message":"Unity is busy running 'compile'. Retry 'get-logs' after the running tool completes."}`), } var stderr bytes.Buffer @@ -260,6 +267,15 @@ func TestWriteClassifiedServerBusyRPCErrorWritesBusyStatus(t *testing.T) { if envelope.Message != expectedMessage { t.Fatalf("message mismatch: %#v", envelope) } + if envelope.RunningToolName != "compile" || envelope.RequestedToolName != "get-logs" { + t.Fatalf("tool names mismatch: %#v", envelope) + } + if envelope.IsPlaying == nil || *envelope.IsPlaying != true { + t.Fatalf("isPlaying mismatch: %#v", envelope) + } + if envelope.IsPaused == nil || *envelope.IsPaused != true { + t.Fatalf("isPaused mismatch: %#v", envelope) + } if bytes.Contains(stderr.Bytes(), []byte("Success")) || bytes.Contains(stderr.Bytes(), []byte("errorCode")) { t.Fatalf("busy output should not include error envelope fields: %s", stderr.String()) } @@ -437,7 +453,7 @@ func TestClassifyConnectionAttemptUsesContextProjectRootFallback(t *testing.T) { func TestAvailableCommandNamesIncludesBuiltIns(t *testing.T) { names := availableCommandNames(toolsCache{}) - expectedBuiltIns := []string{"launch", "list", "sync", "focus-window", "wait-for-debug-break", "skills", "completion", "install", "update"} + expectedBuiltIns := []string{"launch", "list", "sync", "focus-window", "wait-for-debug-break", "debug-break-status", "skills", "completion", "install", "update"} for index, expected := range expectedBuiltIns { if names[index] != expected { t.Fatalf("built-in command mismatch: %#v", names) diff --git a/cli/internal/cli/run.go b/cli/internal/cli/run.go index c9325ff1fc..ef79fac621 100644 --- a/cli/internal/cli/run.go +++ b/cli/internal/cli/run.go @@ -88,6 +88,8 @@ func RunProjectLocal(ctx context.Context, args []string, stdout io.Writer, stder return runFocusWindow(ctx, connection.ProjectRoot, stdout, stderr) case debugBreakWaitCommandName: return runWaitForDebugBreakCommand(ctx, connection, commandArgs, stdout, stderr) + case debugBreakStatusUserCommandName: + return runDebugBreakStatusCommand(ctx, connection, commandArgs, stdout, stderr) default: tool, cache, ok, err := findToolForCommand(connection.ProjectRoot, command) if err != nil { From f10106361f752740bb316f50d9d032ac3549b896 Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 3 Jun 2026 17:55:31 +0900 Subject: [PATCH 09/26] Document debug break inspection workflows Clarify marker placement for keyboard and mouse verification, document debug-break-status, and sync generated agent skills so agents can discover the workflow without relying on prompt-specific instructions. --- .../skills/uloop-simulate-keyboard/SKILL.md | 11 +++- .../uloop-simulate-mouse-input/SKILL.md | 13 +++- .../uloop-wait-for-debug-break/SKILL.md | 64 +++++++++++++++++++ .../skills/uloop-simulate-keyboard/SKILL.md | 11 +++- .../uloop-simulate-mouse-input/SKILL.md | 13 +++- .../uloop-wait-for-debug-break/SKILL.md | 64 +++++++++++++++++++ .../CliOnlyTools~/PausePoint/Skill/SKILL.md | 21 ++++-- .../SimulateKeyboard/Skill/SKILL.md | 8 +++ .../SimulateMouseInput/Skill/SKILL.md | 10 ++- 9 files changed, 204 insertions(+), 11 deletions(-) create mode 100644 .agents/skills/uloop-wait-for-debug-break/SKILL.md create mode 100644 .claude/skills/uloop-wait-for-debug-break/SKILL.md diff --git a/.agents/skills/uloop-simulate-keyboard/SKILL.md b/.agents/skills/uloop-simulate-keyboard/SKILL.md index debad2993c..0614d39c0c 100644 --- a/.agents/skills/uloop-simulate-keyboard/SKILL.md +++ b/.agents/skills/uloop-simulate-keyboard/SKILL.md @@ -14,7 +14,8 @@ Simulate keyboard input on Unity PlayMode: $ARGUMENTS 1. Ensure Unity is in PlayMode (use `uloop control-play-mode --action Play` if not) 2. Execute the appropriate `uloop simulate-keyboard` command 3. Take a screenshot to verify the result: `uloop screenshot --capture-mode rendering` -4. Report what happened +4. If the screenshot cannot prove the gameplay state, place and enable a `UnityCliLoopDebug.Break("")` marker with `uloop enable-debug-break`, run the input again, then inspect while Unity is paused +5. Report what happened ## Tool Reference @@ -43,6 +44,13 @@ Use `Press` for edge-triggered gameplay code such as `Keyboard.current.spaceKey. If a successful `Press` or `KeyDown` leaves `Keyboard.current..isPressed` true but the game state does not change, do not immediately rewrite the user's gameplay code to `isPressed`. First verify that the gameplay component is active during the command, that it polls input in the configured Input System update phase, and that a missed `KeyDown` edge is followed by `KeyUp` before retrying. Use `KeyDown` / `KeyUp` when the scenario intentionally needs a held key. +### Debug Break verification + +- Use `UnityCliLoopDebug.Break("")` when a screenshot cannot prove that the keyboard input changed gameplay state, such as jump, sprint, or interaction. +- Put the marker at a natural state transition after the game consumed the key, such as after jump velocity is applied, not immediately after sending `simulate-keyboard`. +- If the response has `InterruptedByDebugBreak: true`, Unity is paused for inspection and the tool released its held input bookkeeping. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. +- Use distinct marker ids for strict phases, for example `jump-key-read` and `jump-velocity-applied`. + ### KeyDown/KeyUp Rules - `KeyDown` fails if the key is already held @@ -84,6 +92,7 @@ Returns JSON with: - `Message` (string): Description of what happened or why it failed - `Action` (string): The `--action` value that was applied (`Press`, `KeyDown`, or `KeyUp`) - `KeyName` (string, nullable): The key that was acted on; may be `null` when the action could not resolve a key +- `InterruptedByDebugBreak` (boolean): True when Unity paused during Debug Break inspection and the input bookkeeping was safely released ## Prerequisites diff --git a/.agents/skills/uloop-simulate-mouse-input/SKILL.md b/.agents/skills/uloop-simulate-mouse-input/SKILL.md index da66d0b1fd..9e9b2f5357 100644 --- a/.agents/skills/uloop-simulate-mouse-input/SKILL.md +++ b/.agents/skills/uloop-simulate-mouse-input/SKILL.md @@ -15,7 +15,8 @@ Simulate mouse input via Input System in Unity PlayMode: $ARGUMENTS 2. For Click/LongPress: determine the target screen position (use `uloop screenshot` to find coordinates) 3. Execute the appropriate `uloop simulate-mouse-input` command 4. Take a screenshot to verify the result: `uloop screenshot --capture-mode rendering` -5. Report what happened +5. If the screenshot cannot prove the gameplay state, place and enable a `UnityCliLoopDebug.Break("")` marker with `uloop enable-debug-break`, run the input again, then inspect while Unity is paused +6. Report what happened ## Tool Reference @@ -47,6 +48,13 @@ uloop simulate-mouse-input --action [options] | `SmoothDelta` | Mouse.current.delta (per-frame) | Inject mouse delta smoothly over `--duration` seconds (human-like camera pan) | | `Scroll` | Mouse.current.scroll | Inject scroll wheel input (e.g. for hotbar or zoom) | +### Debug Break verification + +- Use `UnityCliLoopDebug.Break("")` when a screenshot cannot prove that mouse input changed gameplay state, such as block hit, camera turn, or item placement. +- Put the marker at a natural state transition after the game consumed the mouse input, such as after a raycast hit, damage application, placement, or camera rotation update, not immediately after sending `simulate-mouse-input`. +- If the response has `InterruptedByDebugBreak: true`, Unity is paused for inspection and the tool released its held input bookkeeping. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. +- Use distinct marker ids for strict phases, for example `block-raycast-hit` and `block-damage-applied`. + ### Global Options (optional) | Option | Description | @@ -106,5 +114,6 @@ Returns JSON with: - `Button`: Which button was used (nullable string; populated for `Click` / `LongPress`, null otherwise) - `PositionX`: Target X coordinate (nullable float; populated for `Click` / `LongPress`) - `PositionY`: Target Y coordinate (nullable float; populated for `Click` / `LongPress`) +- `InterruptedByDebugBreak`: True when Unity paused during Debug Break inspection and the input bookkeeping was safely released -These are the only six fields. There is no `DeltaX`, `DeltaY`, `ScrollX`, `ScrollY`, `Duration`, or hit-element field in the response — only the issued action, button, and target position are echoed back. Verify visual outcome with a follow-up screenshot. +There is no `DeltaX`, `DeltaY`, `ScrollX`, `ScrollY`, `Duration`, or hit-element field in the response — only the issued action, button, target position, and Debug Break interruption state are echoed back. Verify visual outcome with a follow-up screenshot. diff --git a/.agents/skills/uloop-wait-for-debug-break/SKILL.md b/.agents/skills/uloop-wait-for-debug-break/SKILL.md new file mode 100644 index 0000000000..8d72895e9d --- /dev/null +++ b/.agents/skills/uloop-wait-for-debug-break/SKILL.md @@ -0,0 +1,64 @@ +--- +name: uloop-wait-for-debug-break +description: "Pause Unity at a named UnityCliLoopDebug.Break marker when input or gameplay state is hard to verify." +--- + +# uloop wait-for-debug-break + +Pause Unity when execution reaches a named marker in user code. + +## When to use + +- Use when logs or screenshots cannot prove that a gameplay path was reached. +- Use during development debugging, not only E2E tests. +- Use after simulated keyboard or mouse input when you need to inspect exact runtime state. + +## Workflow + +1. Add a marker at the state you want to inspect: + +```csharp +using io.github.hatayama.UnityCliLoop.Runtime; + +UnityCliLoopDebug.Break("player-jumped"); +``` + +2. Compile the project. +3. Enable the marker before triggering the code path: + +```bash +uloop enable-debug-break --id player-jumped --timeout-seconds 30 +``` + +4. Check marker state if needed: + +```bash +uloop debug-break-status --id player-jumped +``` + +5. Trigger the behavior, such as `simulate-keyboard`, `simulate-mouse-input`, UI interaction, or dynamic code. +6. Wait for the marker: + +```bash +uloop wait-for-debug-break --id player-jumped --timeout-seconds 30 +``` + +7. While Unity is paused, inspect state with `uloop get-logs`, `uloop get-hierarchy`, `uloop find-game-objects`, screenshots, or `uloop execute-dynamic-code`. +8. Clear the marker if you stop waiting: + +```bash +uloop clear-debug-break --id player-jumped +``` + +## Marker placement + +- Prefer natural gameplay or state-transition locations after input has been consumed, such as after jump velocity/state changes, physics contact, damage application, or inventory mutation. +- Avoid placing the marker immediately after issuing simulated input unless that exact input handling line is the state you need to inspect; immediate markers can interrupt the input command before the resulting gameplay state settles. +- Use separate ids for strict phases, for example `jump-input-read`, `jump-velocity-applied`, and `jump-landed`, instead of reusing one broad marker. + +## Safety + +- `UnityCliLoopDebug.Break` uses Unity's conditional-call pattern and is compiled out of non-Editor call sites. +- Do not pass side-effect expressions as the id argument. Use stable string ids. +- This does not collect logs or state snapshots. Use existing inspection commands after Unity pauses. +- If `enable-debug-break` warns about Domain Reload before PlayMode, the marker may be cleared when entering PlayMode. Domain Reload disabled is suitable for this workflow; otherwise enable again after PlayMode starts. diff --git a/.claude/skills/uloop-simulate-keyboard/SKILL.md b/.claude/skills/uloop-simulate-keyboard/SKILL.md index debad2993c..0614d39c0c 100644 --- a/.claude/skills/uloop-simulate-keyboard/SKILL.md +++ b/.claude/skills/uloop-simulate-keyboard/SKILL.md @@ -14,7 +14,8 @@ Simulate keyboard input on Unity PlayMode: $ARGUMENTS 1. Ensure Unity is in PlayMode (use `uloop control-play-mode --action Play` if not) 2. Execute the appropriate `uloop simulate-keyboard` command 3. Take a screenshot to verify the result: `uloop screenshot --capture-mode rendering` -4. Report what happened +4. If the screenshot cannot prove the gameplay state, place and enable a `UnityCliLoopDebug.Break("")` marker with `uloop enable-debug-break`, run the input again, then inspect while Unity is paused +5. Report what happened ## Tool Reference @@ -43,6 +44,13 @@ Use `Press` for edge-triggered gameplay code such as `Keyboard.current.spaceKey. If a successful `Press` or `KeyDown` leaves `Keyboard.current..isPressed` true but the game state does not change, do not immediately rewrite the user's gameplay code to `isPressed`. First verify that the gameplay component is active during the command, that it polls input in the configured Input System update phase, and that a missed `KeyDown` edge is followed by `KeyUp` before retrying. Use `KeyDown` / `KeyUp` when the scenario intentionally needs a held key. +### Debug Break verification + +- Use `UnityCliLoopDebug.Break("")` when a screenshot cannot prove that the keyboard input changed gameplay state, such as jump, sprint, or interaction. +- Put the marker at a natural state transition after the game consumed the key, such as after jump velocity is applied, not immediately after sending `simulate-keyboard`. +- If the response has `InterruptedByDebugBreak: true`, Unity is paused for inspection and the tool released its held input bookkeeping. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. +- Use distinct marker ids for strict phases, for example `jump-key-read` and `jump-velocity-applied`. + ### KeyDown/KeyUp Rules - `KeyDown` fails if the key is already held @@ -84,6 +92,7 @@ Returns JSON with: - `Message` (string): Description of what happened or why it failed - `Action` (string): The `--action` value that was applied (`Press`, `KeyDown`, or `KeyUp`) - `KeyName` (string, nullable): The key that was acted on; may be `null` when the action could not resolve a key +- `InterruptedByDebugBreak` (boolean): True when Unity paused during Debug Break inspection and the input bookkeeping was safely released ## Prerequisites diff --git a/.claude/skills/uloop-simulate-mouse-input/SKILL.md b/.claude/skills/uloop-simulate-mouse-input/SKILL.md index da66d0b1fd..9e9b2f5357 100644 --- a/.claude/skills/uloop-simulate-mouse-input/SKILL.md +++ b/.claude/skills/uloop-simulate-mouse-input/SKILL.md @@ -15,7 +15,8 @@ Simulate mouse input via Input System in Unity PlayMode: $ARGUMENTS 2. For Click/LongPress: determine the target screen position (use `uloop screenshot` to find coordinates) 3. Execute the appropriate `uloop simulate-mouse-input` command 4. Take a screenshot to verify the result: `uloop screenshot --capture-mode rendering` -5. Report what happened +5. If the screenshot cannot prove the gameplay state, place and enable a `UnityCliLoopDebug.Break("")` marker with `uloop enable-debug-break`, run the input again, then inspect while Unity is paused +6. Report what happened ## Tool Reference @@ -47,6 +48,13 @@ uloop simulate-mouse-input --action [options] | `SmoothDelta` | Mouse.current.delta (per-frame) | Inject mouse delta smoothly over `--duration` seconds (human-like camera pan) | | `Scroll` | Mouse.current.scroll | Inject scroll wheel input (e.g. for hotbar or zoom) | +### Debug Break verification + +- Use `UnityCliLoopDebug.Break("")` when a screenshot cannot prove that mouse input changed gameplay state, such as block hit, camera turn, or item placement. +- Put the marker at a natural state transition after the game consumed the mouse input, such as after a raycast hit, damage application, placement, or camera rotation update, not immediately after sending `simulate-mouse-input`. +- If the response has `InterruptedByDebugBreak: true`, Unity is paused for inspection and the tool released its held input bookkeeping. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. +- Use distinct marker ids for strict phases, for example `block-raycast-hit` and `block-damage-applied`. + ### Global Options (optional) | Option | Description | @@ -106,5 +114,6 @@ Returns JSON with: - `Button`: Which button was used (nullable string; populated for `Click` / `LongPress`, null otherwise) - `PositionX`: Target X coordinate (nullable float; populated for `Click` / `LongPress`) - `PositionY`: Target Y coordinate (nullable float; populated for `Click` / `LongPress`) +- `InterruptedByDebugBreak`: True when Unity paused during Debug Break inspection and the input bookkeeping was safely released -These are the only six fields. There is no `DeltaX`, `DeltaY`, `ScrollX`, `ScrollY`, `Duration`, or hit-element field in the response — only the issued action, button, and target position are echoed back. Verify visual outcome with a follow-up screenshot. +There is no `DeltaX`, `DeltaY`, `ScrollX`, `ScrollY`, `Duration`, or hit-element field in the response — only the issued action, button, target position, and Debug Break interruption state are echoed back. Verify visual outcome with a follow-up screenshot. diff --git a/.claude/skills/uloop-wait-for-debug-break/SKILL.md b/.claude/skills/uloop-wait-for-debug-break/SKILL.md new file mode 100644 index 0000000000..8d72895e9d --- /dev/null +++ b/.claude/skills/uloop-wait-for-debug-break/SKILL.md @@ -0,0 +1,64 @@ +--- +name: uloop-wait-for-debug-break +description: "Pause Unity at a named UnityCliLoopDebug.Break marker when input or gameplay state is hard to verify." +--- + +# uloop wait-for-debug-break + +Pause Unity when execution reaches a named marker in user code. + +## When to use + +- Use when logs or screenshots cannot prove that a gameplay path was reached. +- Use during development debugging, not only E2E tests. +- Use after simulated keyboard or mouse input when you need to inspect exact runtime state. + +## Workflow + +1. Add a marker at the state you want to inspect: + +```csharp +using io.github.hatayama.UnityCliLoop.Runtime; + +UnityCliLoopDebug.Break("player-jumped"); +``` + +2. Compile the project. +3. Enable the marker before triggering the code path: + +```bash +uloop enable-debug-break --id player-jumped --timeout-seconds 30 +``` + +4. Check marker state if needed: + +```bash +uloop debug-break-status --id player-jumped +``` + +5. Trigger the behavior, such as `simulate-keyboard`, `simulate-mouse-input`, UI interaction, or dynamic code. +6. Wait for the marker: + +```bash +uloop wait-for-debug-break --id player-jumped --timeout-seconds 30 +``` + +7. While Unity is paused, inspect state with `uloop get-logs`, `uloop get-hierarchy`, `uloop find-game-objects`, screenshots, or `uloop execute-dynamic-code`. +8. Clear the marker if you stop waiting: + +```bash +uloop clear-debug-break --id player-jumped +``` + +## Marker placement + +- Prefer natural gameplay or state-transition locations after input has been consumed, such as after jump velocity/state changes, physics contact, damage application, or inventory mutation. +- Avoid placing the marker immediately after issuing simulated input unless that exact input handling line is the state you need to inspect; immediate markers can interrupt the input command before the resulting gameplay state settles. +- Use separate ids for strict phases, for example `jump-input-read`, `jump-velocity-applied`, and `jump-landed`, instead of reusing one broad marker. + +## Safety + +- `UnityCliLoopDebug.Break` uses Unity's conditional-call pattern and is compiled out of non-Editor call sites. +- Do not pass side-effect expressions as the id argument. Use stable string ids. +- This does not collect logs or state snapshots. Use existing inspection commands after Unity pauses. +- If `enable-debug-break` warns about Domain Reload before PlayMode, the marker may be cleared when entering PlayMode. Domain Reload disabled is suitable for this workflow; otherwise enable again after PlayMode starts. diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md index 07b2af6300..8d72895e9d 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -30,22 +30,35 @@ UnityCliLoopDebug.Break("player-jumped"); uloop enable-debug-break --id player-jumped --timeout-seconds 30 ``` -4. Trigger the behavior, such as `simulate-keyboard`, `simulate-mouse-input`, UI interaction, or dynamic code. -5. Wait for the marker: +4. Check marker state if needed: + +```bash +uloop debug-break-status --id player-jumped +``` + +5. Trigger the behavior, such as `simulate-keyboard`, `simulate-mouse-input`, UI interaction, or dynamic code. +6. Wait for the marker: ```bash uloop wait-for-debug-break --id player-jumped --timeout-seconds 30 ``` -6. While Unity is paused, inspect state with `uloop get-logs`, `uloop get-hierarchy`, `uloop find-game-objects`, screenshots, or `uloop execute-dynamic-code`. -7. Clear the marker if you stop waiting: +7. While Unity is paused, inspect state with `uloop get-logs`, `uloop get-hierarchy`, `uloop find-game-objects`, screenshots, or `uloop execute-dynamic-code`. +8. Clear the marker if you stop waiting: ```bash uloop clear-debug-break --id player-jumped ``` +## Marker placement + +- Prefer natural gameplay or state-transition locations after input has been consumed, such as after jump velocity/state changes, physics contact, damage application, or inventory mutation. +- Avoid placing the marker immediately after issuing simulated input unless that exact input handling line is the state you need to inspect; immediate markers can interrupt the input command before the resulting gameplay state settles. +- Use separate ids for strict phases, for example `jump-input-read`, `jump-velocity-applied`, and `jump-landed`, instead of reusing one broad marker. + ## Safety - `UnityCliLoopDebug.Break` uses Unity's conditional-call pattern and is compiled out of non-Editor call sites. - Do not pass side-effect expressions as the id argument. Use stable string ids. - This does not collect logs or state snapshots. Use existing inspection commands after Unity pauses. +- If `enable-debug-break` warns about Domain Reload before PlayMode, the marker may be cleared when entering PlayMode. Domain Reload disabled is suitable for this workflow; otherwise enable again after PlayMode starts. diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md index 82f4577fda..0614d39c0c 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md @@ -44,6 +44,13 @@ Use `Press` for edge-triggered gameplay code such as `Keyboard.current.spaceKey. If a successful `Press` or `KeyDown` leaves `Keyboard.current..isPressed` true but the game state does not change, do not immediately rewrite the user's gameplay code to `isPressed`. First verify that the gameplay component is active during the command, that it polls input in the configured Input System update phase, and that a missed `KeyDown` edge is followed by `KeyUp` before retrying. Use `KeyDown` / `KeyUp` when the scenario intentionally needs a held key. +### Debug Break verification + +- Use `UnityCliLoopDebug.Break("")` when a screenshot cannot prove that the keyboard input changed gameplay state, such as jump, sprint, or interaction. +- Put the marker at a natural state transition after the game consumed the key, such as after jump velocity is applied, not immediately after sending `simulate-keyboard`. +- If the response has `InterruptedByDebugBreak: true`, Unity is paused for inspection and the tool released its held input bookkeeping. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. +- Use distinct marker ids for strict phases, for example `jump-key-read` and `jump-velocity-applied`. + ### KeyDown/KeyUp Rules - `KeyDown` fails if the key is already held @@ -85,6 +92,7 @@ Returns JSON with: - `Message` (string): Description of what happened or why it failed - `Action` (string): The `--action` value that was applied (`Press`, `KeyDown`, or `KeyUp`) - `KeyName` (string, nullable): The key that was acted on; may be `null` when the action could not resolve a key +- `InterruptedByDebugBreak` (boolean): True when Unity paused during Debug Break inspection and the input bookkeeping was safely released ## Prerequisites diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md index b0d33c5e23..9e9b2f5357 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md @@ -48,6 +48,13 @@ uloop simulate-mouse-input --action [options] | `SmoothDelta` | Mouse.current.delta (per-frame) | Inject mouse delta smoothly over `--duration` seconds (human-like camera pan) | | `Scroll` | Mouse.current.scroll | Inject scroll wheel input (e.g. for hotbar or zoom) | +### Debug Break verification + +- Use `UnityCliLoopDebug.Break("")` when a screenshot cannot prove that mouse input changed gameplay state, such as block hit, camera turn, or item placement. +- Put the marker at a natural state transition after the game consumed the mouse input, such as after a raycast hit, damage application, placement, or camera rotation update, not immediately after sending `simulate-mouse-input`. +- If the response has `InterruptedByDebugBreak: true`, Unity is paused for inspection and the tool released its held input bookkeeping. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. +- Use distinct marker ids for strict phases, for example `block-raycast-hit` and `block-damage-applied`. + ### Global Options (optional) | Option | Description | @@ -107,5 +114,6 @@ Returns JSON with: - `Button`: Which button was used (nullable string; populated for `Click` / `LongPress`, null otherwise) - `PositionX`: Target X coordinate (nullable float; populated for `Click` / `LongPress`) - `PositionY`: Target Y coordinate (nullable float; populated for `Click` / `LongPress`) +- `InterruptedByDebugBreak`: True when Unity paused during Debug Break inspection and the input bookkeeping was safely released -These are the only six fields. There is no `DeltaX`, `DeltaY`, `ScrollX`, `ScrollY`, `Duration`, or hit-element field in the response — only the issued action, button, and target position are echoed back. Verify visual outcome with a follow-up screenshot. +There is no `DeltaX`, `DeltaY`, `ScrollX`, `ScrollY`, `Duration`, or hit-element field in the response — only the issued action, button, target position, and Debug Break interruption state are echoed back. Verify visual outcome with a follow-up screenshot. From b3ee217b9fc8ff381d28450ad319a3bd8fb3798e Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 3 Jun 2026 19:19:03 +0900 Subject: [PATCH 10/26] Improve debug break timeout diagnostics Report enabled terminology and pause/play details in debug break wait failures so agents can distinguish a missed marker path from an already-paused Editor or marker lifetime issue. --- .../uloop-wait-for-debug-break/SKILL.md | 3 + .../uloop-wait-for-debug-break/SKILL.md | 3 + Assets/Tests/Editor/PausePointTests.cs | 30 ++--- .../CliOnlyTools~/PausePoint/Skill/SKILL.md | 3 + .../PausePoint/PausePointTools.cs | 6 +- .../Api/PausePointStatusBridgeCommand.cs | 4 +- .../PausePoints/UloopPausePointRegistry.cs | 58 ++++---- .../Runtime/PausePoints/UnityCliLoopDebug.cs | 2 +- cli/internal/cli/debug_break_wait.go | 56 +++++--- cli/internal/cli/debug_break_wait_test.go | 124 ++++++++++++++++-- cli/internal/cli/error_envelope.go | 2 +- 11 files changed, 210 insertions(+), 81 deletions(-) diff --git a/.agents/skills/uloop-wait-for-debug-break/SKILL.md b/.agents/skills/uloop-wait-for-debug-break/SKILL.md index 8d72895e9d..97b4ec7730 100644 --- a/.agents/skills/uloop-wait-for-debug-break/SKILL.md +++ b/.agents/skills/uloop-wait-for-debug-break/SKILL.md @@ -43,6 +43,8 @@ uloop debug-break-status --id player-jumped uloop wait-for-debug-break --id player-jumped --timeout-seconds 30 ``` +If this command times out, the marker line was not reached while the command waited. Inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, and `remainingMilliseconds` to distinguish input not being consumed, gameplay conditions not being met, an id mismatch, or Unity already being paused. + 7. While Unity is paused, inspect state with `uloop get-logs`, `uloop get-hierarchy`, `uloop find-game-objects`, screenshots, or `uloop execute-dynamic-code`. 8. Clear the marker if you stop waiting: @@ -59,6 +61,7 @@ uloop clear-debug-break --id player-jumped ## Safety - `UnityCliLoopDebug.Break` uses Unity's conditional-call pattern and is compiled out of non-Editor call sites. +- Code in a custom asmdef must reference `UnityCLILoop.PausePoints.Runtime` to use `UnityCliLoopDebug.Break`. - Do not pass side-effect expressions as the id argument. Use stable string ids. - This does not collect logs or state snapshots. Use existing inspection commands after Unity pauses. - If `enable-debug-break` warns about Domain Reload before PlayMode, the marker may be cleared when entering PlayMode. Domain Reload disabled is suitable for this workflow; otherwise enable again after PlayMode starts. diff --git a/.claude/skills/uloop-wait-for-debug-break/SKILL.md b/.claude/skills/uloop-wait-for-debug-break/SKILL.md index 8d72895e9d..97b4ec7730 100644 --- a/.claude/skills/uloop-wait-for-debug-break/SKILL.md +++ b/.claude/skills/uloop-wait-for-debug-break/SKILL.md @@ -43,6 +43,8 @@ uloop debug-break-status --id player-jumped uloop wait-for-debug-break --id player-jumped --timeout-seconds 30 ``` +If this command times out, the marker line was not reached while the command waited. Inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, and `remainingMilliseconds` to distinguish input not being consumed, gameplay conditions not being met, an id mismatch, or Unity already being paused. + 7. While Unity is paused, inspect state with `uloop get-logs`, `uloop get-hierarchy`, `uloop find-game-objects`, screenshots, or `uloop execute-dynamic-code`. 8. Clear the marker if you stop waiting: @@ -59,6 +61,7 @@ uloop clear-debug-break --id player-jumped ## Safety - `UnityCliLoopDebug.Break` uses Unity's conditional-call pattern and is compiled out of non-Editor call sites. +- Code in a custom asmdef must reference `UnityCLILoop.PausePoints.Runtime` to use `UnityCliLoopDebug.Break`. - Do not pass side-effect expressions as the id argument. Use stable string ids. - This does not collect logs or state snapshots. Use existing inspection commands after Unity pauses. - If `enable-debug-break` warns about Domain Reload before PlayMode, the marker may be cleared when entering PlayMode. Domain Reload disabled is suitable for this workflow; otherwise enable again after PlayMode starts. diff --git a/Assets/Tests/Editor/PausePointTests.cs b/Assets/Tests/Editor/PausePointTests.cs index 2d09084b57..a52f5aa111 100644 --- a/Assets/Tests/Editor/PausePointTests.cs +++ b/Assets/Tests/Editor/PausePointTests.cs @@ -41,23 +41,23 @@ public void TearDown() } [Test] - public void Break_WhenPausePointIsNotArmed_DoesNotPause() + public void Break_WhenPausePointIsNotEnabled_DoesNotPause() { - // Verifies marker calls are no-op until the CLI arms the same id. + // Verifies marker calls are no-op until the CLI enables the same id. UnityCliLoopDebug.Break("jump"); UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("jump"); Assert.That(_pauseController.PauseCount, Is.EqualTo(0)); - Assert.That(snapshot.Status, Is.EqualTo(UloopPausePointStatus.NotArmed)); - Assert.That(snapshot.IsArmed, Is.False); + Assert.That(snapshot.Status, Is.EqualTo(UloopPausePointStatus.NotEnabled)); + Assert.That(snapshot.IsEnabled, Is.False); } [Test] - public void Break_WhenPausePointIsArmed_RecordsHitAndRequestsPause() + public void Break_WhenPausePointIsEnabled_RecordsHitAndRequestsPause() { - // Verifies an armed marker hit records state and requests a Unity pause. - UloopPausePointRegistry.Arm("jump", 30); + // Verifies an enabled marker hit records state and requests a Unity pause. + UloopPausePointRegistry.Enable("jump", 30); UnityCliLoopDebug.Break("jump"); @@ -65,7 +65,7 @@ public void Break_WhenPausePointIsArmed_RecordsHitAndRequestsPause() Assert.That(_pauseController.PauseCount, Is.EqualTo(1)); Assert.That(snapshot.Status, Is.EqualTo(UloopPausePointStatus.Hit)); Assert.That(snapshot.IsHit, Is.True); - Assert.That(snapshot.IsArmed, Is.False); + Assert.That(snapshot.IsEnabled, Is.False); Assert.That(snapshot.IsPaused, Is.True); Assert.That(snapshot.HitCount, Is.EqualTo(1)); } @@ -73,29 +73,29 @@ public void Break_WhenPausePointIsArmed_RecordsHitAndRequestsPause() [Test] public void GetStatus_WhenTimeoutPasses_ExpiresAndDisarms() { - // Verifies timeout disarms the marker before a late hit can pause Unity. - UloopPausePointRegistry.Arm("jump", 1); + // Verifies timeout disables the marker before a late hit can pause Unity. + UloopPausePointRegistry.Enable("jump", 1); _nowUtc = _nowUtc.AddSeconds(2); UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("jump"); UnityCliLoopDebug.Break("jump"); Assert.That(snapshot.Status, Is.EqualTo(UloopPausePointStatus.Expired)); - Assert.That(snapshot.IsArmed, Is.False); + Assert.That(snapshot.IsEnabled, Is.False); Assert.That(_pauseController.PauseCount, Is.EqualTo(0)); } [Test] - public void Clear_WhenPausePointIsArmed_DisarmsWithoutPause() + public void Clear_WhenPausePointIsEnabled_DisablesWithoutPause() { // Verifies explicit clear prevents later marker hits from pausing Unity. - UloopPausePointRegistry.Arm("jump", 30); + UloopPausePointRegistry.Enable("jump", 30); UloopPausePointSnapshot snapshot = UloopPausePointRegistry.Clear("jump"); UnityCliLoopDebug.Break("jump"); Assert.That(snapshot.Status, Is.EqualTo(UloopPausePointStatus.Cleared)); - Assert.That(snapshot.IsArmed, Is.False); + Assert.That(snapshot.IsEnabled, Is.False); Assert.That(_pauseController.PauseCount, Is.EqualTo(0)); } @@ -103,7 +103,7 @@ public void Clear_WhenPausePointIsArmed_DisarmsWithoutPause() public void ClearAll_WhenPausePointWasHit_ClearsTerminalStatus() { // Verifies bulk clear hides stale terminal hit status from future waits. - UloopPausePointRegistry.Arm("jump", 30); + UloopPausePointRegistry.Enable("jump", 30); UnityCliLoopDebug.Break("jump"); UloopPausePointClearAllResult result = UloopPausePointRegistry.ClearAll(); diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md index 8d72895e9d..97b4ec7730 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -43,6 +43,8 @@ uloop debug-break-status --id player-jumped uloop wait-for-debug-break --id player-jumped --timeout-seconds 30 ``` +If this command times out, the marker line was not reached while the command waited. Inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, and `remainingMilliseconds` to distinguish input not being consumed, gameplay conditions not being met, an id mismatch, or Unity already being paused. + 7. While Unity is paused, inspect state with `uloop get-logs`, `uloop get-hierarchy`, `uloop find-game-objects`, screenshots, or `uloop execute-dynamic-code`. 8. Clear the marker if you stop waiting: @@ -59,6 +61,7 @@ uloop clear-debug-break --id player-jumped ## Safety - `UnityCliLoopDebug.Break` uses Unity's conditional-call pattern and is compiled out of non-Editor call sites. +- Code in a custom asmdef must reference `UnityCLILoop.PausePoints.Runtime` to use `UnityCliLoopDebug.Break`. - Do not pass side-effect expressions as the id argument. Use stable string ids. - This does not collect logs or state snapshots. Use existing inspection commands after Unity pauses. - If `enable-debug-break` warns about Domain Reload before PlayMode, the marker may be cleared when entering PlayMode. Domain Reload disabled is suitable for this workflow; otherwise enable again after PlayMode starts. diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs index 6a46d7ce0b..9edb8664a2 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs @@ -35,7 +35,7 @@ public class PausePointResponse : UnityCliLoopToolResponse { public string Id { get; set; } = string.Empty; public string Status { get; set; } = string.Empty; - public bool IsArmed { get; set; } + public bool IsEnabled { get; set; } public bool IsHit { get; set; } public int HitCount { get; set; } public int TimeoutSeconds { get; set; } @@ -57,7 +57,7 @@ internal static PausePointResponse FromSnapshot(UloopPausePointSnapshot snapshot { Id = snapshot.Id, Status = snapshot.Status, - IsArmed = snapshot.IsArmed, + IsEnabled = snapshot.IsEnabled, IsHit = snapshot.IsHit, HitCount = snapshot.HitCount, TimeoutSeconds = snapshot.TimeoutSeconds, @@ -136,7 +136,7 @@ public PausePointResponse Enable(EnablePausePointSchema parameters) throw new UnityCliLoopToolParameterValidationException("TimeoutSeconds must be greater than zero."); } - UloopPausePointSnapshot snapshot = UloopPausePointRegistry.Arm(id, parameters.TimeoutSeconds); + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.Enable(id, parameters.TimeoutSeconds); PausePointResponse response = PausePointResponse.FromSnapshot(snapshot); response.Warning = CreateEnableWarning(); return response; diff --git a/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs b/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs index 6d8b740de6..5f547e34f1 100644 --- a/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs +++ b/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs @@ -46,7 +46,7 @@ public class PausePointStatusResponse : UnityCliLoopToolResponse { public string Id { get; set; } = string.Empty; public string Status { get; set; } = string.Empty; - public bool IsArmed { get; set; } + public bool IsEnabled { get; set; } public bool IsHit { get; set; } public int HitCount { get; set; } public int TimeoutSeconds { get; set; } @@ -66,7 +66,7 @@ internal static PausePointStatusResponse FromSnapshot(UloopPausePointSnapshot sn { Id = snapshot.Id, Status = snapshot.Status, - IsArmed = snapshot.IsArmed, + IsEnabled = snapshot.IsEnabled, IsHit = snapshot.IsHit, HitCount = snapshot.HitCount, TimeoutSeconds = snapshot.TimeoutSeconds, diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs index 2ed804a1c8..a2a1bd63f1 100644 --- a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs +++ b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs @@ -7,7 +7,7 @@ namespace io.github.hatayama.UnityCliLoop.Runtime { /// - /// Stores armed pause point state for the current Editor domain. + /// Stores enabled pause point state for the current Editor domain. /// internal static class UloopPausePointRegistry { @@ -17,7 +17,7 @@ internal static class UloopPausePointRegistry private static IUloopPausePointPauseController _pauseController = new UnityEditorPausePointPauseController(); private static Func _nowProvider = () => DateTime.UtcNow; - public static UloopPausePointSnapshot Arm(string id, int timeoutSeconds) + public static UloopPausePointSnapshot Enable(string id, int timeoutSeconds) { Debug.Assert(!string.IsNullOrWhiteSpace(id), "id must not be null or empty"); Debug.Assert(timeoutSeconds > 0, "timeoutSeconds must be greater than zero"); @@ -35,7 +35,7 @@ public static UloopPausePointSnapshot Clear(string id) DateTime now = NowUtc(); if (!Entries.ContainsKey(id)) { - return UloopPausePointSnapshot.NotArmed(id, _pauseController); + return UloopPausePointSnapshot.NotEnabled(id, _pauseController); } UloopPausePointEntry entry = Entries[id]; @@ -68,7 +68,7 @@ public static UloopPausePointSnapshot GetStatus(string id) DateTime now = NowUtc(); if (!Entries.ContainsKey(id)) { - return UloopPausePointSnapshot.NotArmed(id, _pauseController); + return UloopPausePointSnapshot.NotEnabled(id, _pauseController); } UloopPausePointEntry entry = Entries[id]; @@ -81,18 +81,18 @@ public static UloopPausePointSnapshot Hit(string id) if (string.IsNullOrWhiteSpace(id)) { Debug.Assert(false, "id must not be null or empty"); - return UloopPausePointSnapshot.NotArmed(id ?? string.Empty, _pauseController); + return UloopPausePointSnapshot.NotEnabled(id ?? string.Empty, _pauseController); } DateTime now = NowUtc(); if (!Entries.ContainsKey(id)) { - return UloopPausePointSnapshot.NotArmed(id, _pauseController); + return UloopPausePointSnapshot.NotEnabled(id, _pauseController); } UloopPausePointEntry entry = Entries[id]; entry.ExpireIfNeeded(now); - if (!entry.IsArmed) + if (!entry.IsEnabled) { return entry.ToSnapshot(now, _pauseController); } @@ -157,7 +157,7 @@ internal sealed class UloopPausePointSnapshot public UloopPausePointSnapshot( string id, string status, - bool isArmed, + bool isEnabled, bool isHit, int hitCount, int timeoutSeconds, @@ -167,8 +167,8 @@ public UloopPausePointSnapshot( string message) { Id = id ?? string.Empty; - Status = status ?? UloopPausePointStatus.NotArmed; - IsArmed = isArmed; + Status = status ?? UloopPausePointStatus.NotEnabled; + IsEnabled = isEnabled; IsHit = isHit; HitCount = hitCount; TimeoutSeconds = timeoutSeconds; @@ -180,7 +180,7 @@ public UloopPausePointSnapshot( public string Id { get; } public string Status { get; } - public bool IsArmed { get; } + public bool IsEnabled { get; } public bool IsHit { get; } public int HitCount { get; } public int TimeoutSeconds { get; } @@ -189,13 +189,13 @@ public UloopPausePointSnapshot( public bool IsPaused { get; } public string Message { get; } - public static UloopPausePointSnapshot NotArmed(string id, IUloopPausePointPauseController pauseController) + public static UloopPausePointSnapshot NotEnabled(string id, IUloopPausePointPauseController pauseController) { Debug.Assert(pauseController != null, "pauseController must not be null"); return new UloopPausePointSnapshot( id, - UloopPausePointStatus.NotArmed, + UloopPausePointStatus.NotEnabled, false, false, 0, @@ -223,27 +223,27 @@ public UloopPausePointClearAllResult(int clearedCount, DateTime clearedAtUtc) } /// - /// Owns mutable state for one armed pause point id. + /// Owns mutable state for one enabled pause point id. /// internal sealed class UloopPausePointEntry { - public UloopPausePointEntry(string id, int timeoutSeconds, DateTime armedAtUtc) + public UloopPausePointEntry(string id, int timeoutSeconds, DateTime enabledAtUtc) { Id = id; TimeoutSeconds = timeoutSeconds; - ArmedAtUtc = armedAtUtc; - ExpiresAtUtc = armedAtUtc.AddSeconds(timeoutSeconds); - Status = UloopPausePointStatus.Armed; - IsArmed = true; + EnabledAtUtc = enabledAtUtc; + ExpiresAtUtc = enabledAtUtc.AddSeconds(timeoutSeconds); + Status = UloopPausePointStatus.Enabled; + IsEnabled = true; Message = "Debug break enabled."; } public string Id { get; } public int TimeoutSeconds { get; } - public DateTime ArmedAtUtc { get; } + public DateTime EnabledAtUtc { get; } public DateTime ExpiresAtUtc { get; } public string Status { get; private set; } - public bool IsArmed { get; private set; } + public bool IsEnabled { get; private set; } public int HitCount { get; private set; } public DateTime HitAtUtc { get; private set; } public bool IsPlayingAtHit { get; private set; } @@ -252,7 +252,7 @@ public UloopPausePointEntry(string id, int timeoutSeconds, DateTime armedAtUtc) public void ExpireIfNeeded(DateTime nowUtc) { - if (!IsArmed) + if (!IsEnabled) { return; } @@ -262,14 +262,14 @@ public void ExpireIfNeeded(DateTime nowUtc) return; } - IsArmed = false; + IsEnabled = false; Status = UloopPausePointStatus.Expired; Message = "Debug break expired before it was hit."; } public void MarkCleared() { - IsArmed = false; + IsEnabled = false; Status = UloopPausePointStatus.Cleared; Message = "Debug break cleared."; } @@ -280,7 +280,7 @@ public void RecordHit(DateTime nowUtc, bool isPlaying, bool isPaused) HitAtUtc = nowUtc; IsPlayingAtHit = isPlaying; IsPausedAtHit = isPaused; - IsArmed = false; + IsEnabled = false; Status = UloopPausePointStatus.Hit; Message = "Debug break hit; Unity pause was requested."; } @@ -292,12 +292,12 @@ public UloopPausePointSnapshot ToSnapshot(DateTime nowUtc, IUloopPausePointPause bool isHit = Status == UloopPausePointStatus.Hit; bool isPlaying = isHit ? IsPlayingAtHit : pauseController.IsPlaying; bool isPaused = isHit ? IsPausedAtHit : pauseController.IsPaused; - long elapsedMilliseconds = Math.Max(0, (long)(nowUtc - ArmedAtUtc).TotalMilliseconds); + long elapsedMilliseconds = Math.Max(0, (long)(nowUtc - EnabledAtUtc).TotalMilliseconds); return new UloopPausePointSnapshot( Id, Status, - IsArmed, + IsEnabled, isHit, HitCount, TimeoutSeconds, @@ -313,8 +313,8 @@ public UloopPausePointSnapshot ToSnapshot(DateTime nowUtc, IUloopPausePointPause /// internal static class UloopPausePointStatus { - public const string NotArmed = "NotArmed"; - public const string Armed = "Armed"; + public const string NotEnabled = "NotEnabled"; + public const string Enabled = "Enabled"; public const string Hit = "Hit"; public const string Expired = "Expired"; public const string Cleared = "Cleared"; diff --git a/Packages/src/Runtime/PausePoints/UnityCliLoopDebug.cs b/Packages/src/Runtime/PausePoints/UnityCliLoopDebug.cs index ce851765d4..5c51e866fa 100644 --- a/Packages/src/Runtime/PausePoints/UnityCliLoopDebug.cs +++ b/Packages/src/Runtime/PausePoints/UnityCliLoopDebug.cs @@ -8,7 +8,7 @@ namespace io.github.hatayama.UnityCliLoop.Runtime public static class UnityCliLoopDebug { /// - /// Breaks at a named marker when the Editor has armed the same id. + /// Breaks at a named marker when the Editor has enabled the same id. /// [Conditional("UNITY_EDITOR")] public static void Break(string id) diff --git a/cli/internal/cli/debug_break_wait.go b/cli/internal/cli/debug_break_wait.go index 3451c09274..48ebd8e99e 100644 --- a/cli/internal/cli/debug_break_wait.go +++ b/cli/internal/cli/debug_break_wait.go @@ -20,9 +20,9 @@ const ( debugBreakTimeoutFlagName = "timeout-seconds" debugBreakDefaultTimeoutSeconds = 30 debugBreakStatusProbeTimeout = 5 * time.Second - debugBreakStatusArmed = "Armed" + debugBreakStatusEnabled = "Enabled" debugBreakStatusHit = "Hit" - debugBreakStatusNotArmed = "NotArmed" + debugBreakStatusNotEnabled = "NotEnabled" debugBreakStatusExpired = "Expired" debugBreakStatusCleared = "Cleared" ) @@ -46,7 +46,7 @@ type debugBreakStatusOptions struct { type debugBreakStatusResponse struct { Id string `json:"Id"` Status string `json:"Status"` - IsArmed bool `json:"IsArmed"` + IsEnabled bool `json:"IsEnabled"` IsHit bool `json:"IsHit"` HitCount int `json:"HitCount"` TimeoutSeconds int `json:"TimeoutSeconds"` @@ -59,11 +59,11 @@ type debugBreakStatusResponse struct { type debugBreakWaitState string const ( - debugBreakWaitStateHit debugBreakWaitState = "hit" - debugBreakWaitStateTimeout debugBreakWaitState = "timeout" - debugBreakWaitStateNotArmed debugBreakWaitState = "not_armed" - debugBreakWaitStateExpired debugBreakWaitState = "expired" - debugBreakWaitStateCleared debugBreakWaitState = "cleared" + debugBreakWaitStateHit debugBreakWaitState = "hit" + debugBreakWaitStateTimeout debugBreakWaitState = "timeout" + debugBreakWaitStateNotEnabled debugBreakWaitState = "not_enabled" + debugBreakWaitStateExpired debugBreakWaitState = "expired" + debugBreakWaitStateCleared debugBreakWaitState = "cleared" ) func runWaitForDebugBreakCommand( @@ -307,13 +307,13 @@ func debugBreakWaitStateForStatus(status string) debugBreakWaitState { switch status { case debugBreakStatusHit: return debugBreakWaitStateHit - case debugBreakStatusNotArmed: - return debugBreakWaitStateNotArmed + case debugBreakStatusNotEnabled: + return debugBreakWaitStateNotEnabled case debugBreakStatusExpired: return debugBreakWaitStateExpired case debugBreakStatusCleared: return debugBreakWaitStateCleared - case debugBreakStatusArmed: + case debugBreakStatusEnabled: return "" default: return "" @@ -381,9 +381,9 @@ func debugBreakWaitError( state debugBreakWaitState, ) cliError { switch state { - case debugBreakWaitStateNotArmed: + case debugBreakWaitStateNotEnabled: return debugBreakStateError( - errorCodeDebugBreakNotArmed, + errorCodeDebugBreakNotEnabled, "Debug break is not enabled.", projectRoot, options, @@ -435,13 +435,33 @@ func debugBreakStateError( NextActions: []string{ "Run `uloop enable-debug-break --id ` before waiting.", "Confirm the code path calls `UnityCliLoopDebug.Break(\"\")` with the same id.", + "Check `details.status`, `details.isPlaying`, `details.isPaused`, and `details.remainingMilliseconds` to distinguish a missed code path from an already-paused Editor.", + "If the marker is inside a custom asmdef, add a reference to `UnityCLILoop.PausePoints.Runtime`.", }, Details: map[string]any{ - "id": options.id, - "status": response.Status, - "hitCount": response.HitCount, - "timeoutSeconds": options.timeoutSeconds, - "elapsedMilliseconds": response.ElapsedMilliseconds, + "id": options.id, + "status": response.Status, + "hitCount": response.HitCount, + "timeoutSeconds": options.timeoutSeconds, + "elapsedMilliseconds": response.ElapsedMilliseconds, + "isPlaying": response.IsPlaying, + "isPaused": response.IsPaused, + "remainingMilliseconds": debugBreakRemainingMilliseconds(options, response), + "markerMessage": response.Message, }, } } + +func debugBreakRemainingMilliseconds(options waitForDebugBreakOptions, response debugBreakStatusResponse) int64 { + timeoutSeconds := response.TimeoutSeconds + if timeoutSeconds <= 0 { + return 0 + } + + totalMilliseconds := int64(timeoutSeconds) * int64(time.Second/time.Millisecond) + remainingMilliseconds := totalMilliseconds - response.ElapsedMilliseconds + if remainingMilliseconds <= 0 { + return 0 + } + return remainingMilliseconds +} diff --git a/cli/internal/cli/debug_break_wait_test.go b/cli/internal/cli/debug_break_wait_test.go index 23595590bf..28ef42533d 100644 --- a/cli/internal/cli/debug_break_wait_test.go +++ b/cli/internal/cli/debug_break_wait_test.go @@ -12,7 +12,7 @@ import ( ) // Verifies wait-for-debug-break polls until Unity reports the marker hit. -func TestWaitForDebugBreakReturnsHitAfterArmedStatus(t *testing.T) { +func TestWaitForDebugBreakReturnsHitAfterEnabledStatus(t *testing.T) { originalQuery := queryDebugBreakStatus originalPoll := debugBreakStatusPoll debugBreakStatusPoll = time.Millisecond @@ -22,7 +22,7 @@ func TestWaitForDebugBreakReturnsHitAfterArmedStatus(t *testing.T) { }() responses := []debugBreakStatusResponse{ - {Id: "jump", Status: debugBreakStatusArmed, IsArmed: true}, + {Id: "jump", Status: debugBreakStatusEnabled, IsEnabled: true}, {Id: "jump", Status: debugBreakStatusHit, IsHit: true, IsPaused: true, HitCount: 1}, } requestCount := 0 @@ -58,8 +58,8 @@ func TestWaitForDebugBreakReturnsHitAfterArmedStatus(t *testing.T) { } } -// Verifies wait-for-debug-break clears the arm after its own timeout. -func TestRunWaitForDebugBreakClearsArmAfterTimeout(t *testing.T) { +// Verifies wait-for-debug-break clears the enabled marker after its own timeout. +func TestRunWaitForDebugBreakClearsEnabledMarkerAfterTimeout(t *testing.T) { originalQuery := queryDebugBreakStatus originalClear := clearDebugBreakStatus originalPoll := debugBreakStatusPoll @@ -75,7 +75,16 @@ func TestRunWaitForDebugBreakClearsArmAfterTimeout(t *testing.T) { connection unityipc.Connection, id string, ) (debugBreakStatusResponse, error) { - return debugBreakStatusResponse{Id: id, Status: debugBreakStatusArmed, IsArmed: true}, nil + return debugBreakStatusResponse{ + Id: id, + Status: debugBreakStatusEnabled, + IsEnabled: true, + TimeoutSeconds: 1, + ElapsedMilliseconds: 100, + IsPlaying: true, + IsPaused: false, + Message: "Debug break enabled.", + }, nil } clearedID := "" @@ -105,10 +114,23 @@ func TestRunWaitForDebugBreakClearsArmAfterTimeout(t *testing.T) { if !strings.Contains(stderr.String(), errorCodeDebugBreakWaitTimeout) { t.Fatalf("timeout error missing from stderr: %s", stderr.String()) } + envelope := parseDebugBreakErrorEnvelope(t, stderr.Bytes()) + if envelope.Error.Details["isPlaying"] != true { + t.Fatalf("isPlaying detail mismatch: %#v", envelope.Error.Details) + } + if envelope.Error.Details["isPaused"] != false { + t.Fatalf("isPaused detail mismatch: %#v", envelope.Error.Details) + } + if envelope.Error.Details["markerMessage"] != "Debug break enabled." { + t.Fatalf("markerMessage detail mismatch: %#v", envelope.Error.Details) + } + if envelope.Error.Details["remainingMilliseconds"] != float64(900) { + t.Fatalf("remainingMilliseconds detail mismatch: %#v", envelope.Error.Details) + } } -// Verifies wait-for-debug-break rejects calls before the marker is armed. -func TestWaitForDebugBreakReturnsNotArmedStateImmediately(t *testing.T) { +// Verifies wait-for-debug-break rejects calls before the marker is enabled. +func TestWaitForDebugBreakReturnsNotEnabledStateImmediately(t *testing.T) { originalQuery := queryDebugBreakStatus defer func() { queryDebugBreakStatus = originalQuery @@ -119,7 +141,7 @@ func TestWaitForDebugBreakReturnsNotArmedStateImmediately(t *testing.T) { connection unityipc.Connection, id string, ) (debugBreakStatusResponse, error) { - return debugBreakStatusResponse{Id: id, Status: debugBreakStatusNotArmed}, nil + return debugBreakStatusResponse{Id: id, Status: debugBreakStatusNotEnabled, IsPlaying: true}, nil } response, state, err := waitForDebugBreak(context.Background(), unityipc.Connection{}, waitForDebugBreakOptions{ @@ -130,14 +152,82 @@ func TestWaitForDebugBreakReturnsNotArmedStateImmediately(t *testing.T) { if err != nil { t.Fatalf("waitForDebugBreak failed: %v", err) } - if state != debugBreakWaitStateNotArmed { + if state != debugBreakWaitStateNotEnabled { t.Fatalf("state mismatch: %s", state) } - if response.Status != debugBreakStatusNotArmed { + if response.Status != debugBreakStatusNotEnabled { t.Fatalf("response mismatch: %#v", response) } } +// Verifies not-enabled failures use the user-facing enabled terminology. +func TestRunWaitForDebugBreakReportsNotEnabledError(t *testing.T) { + originalQuery := queryDebugBreakStatus + defer func() { + queryDebugBreakStatus = originalQuery + }() + + queryDebugBreakStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (debugBreakStatusResponse, error) { + return debugBreakStatusResponse{ + Id: id, + Status: debugBreakStatusNotEnabled, + IsPlaying: true, + IsPaused: false, + Message: "Debug break is not enabled.", + }, nil + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runWaitForDebugBreak(context.Background(), unityipc.Connection{}, waitForDebugBreakOptions{ + id: "jump", + timeoutSeconds: 1, + timeout: time.Second, + }, &stdout, &stderr) + + if code != 1 { + t.Fatalf("expected failure, got %d with stdout %s", code, stdout.String()) + } + envelope := parseDebugBreakErrorEnvelope(t, stderr.Bytes()) + if envelope.Error.ErrorCode != errorCodeDebugBreakNotEnabled { + t.Fatalf("error code mismatch: %#v", envelope.Error) + } + if envelope.Error.Details["status"] != debugBreakStatusNotEnabled { + t.Fatalf("status detail mismatch: %#v", envelope.Error.Details) + } + if envelope.Error.Details["isPlaying"] != true || envelope.Error.Details["isPaused"] != false { + t.Fatalf("play state details mismatch: %#v", envelope.Error.Details) + } +} + +// Verifies expired markers report no remaining enabled lifetime. +func TestDebugBreakExpiredErrorReportsNoRemainingTime(t *testing.T) { + response := debugBreakStatusResponse{ + Id: "jump", + Status: debugBreakStatusExpired, + TimeoutSeconds: 1, + ElapsedMilliseconds: 1200, + IsPlaying: true, + Message: "Debug break expired before it was hit.", + } + + cliErr := debugBreakWaitError("/tmp/MyProject", waitForDebugBreakOptions{ + id: "jump", + timeoutSeconds: 1, + }, response, debugBreakWaitStateExpired) + + if cliErr.ErrorCode != errorCodeDebugBreakExpired { + t.Fatalf("error code mismatch: %#v", cliErr) + } + if cliErr.Details["remainingMilliseconds"] != int64(0) { + t.Fatalf("remainingMilliseconds detail mismatch: %#v", cliErr.Details) + } +} + // Verifies wait-for-debug-break requires a marker id. func TestParseWaitForDebugBreakOptionsRequiresID(t *testing.T) { _, err := parseWaitForDebugBreakOptions([]string{"--timeout-seconds", "1"}) @@ -162,7 +252,7 @@ func TestRunDebugBreakStatusReturnsCurrentStatus(t *testing.T) { connection unityipc.Connection, id string, ) (debugBreakStatusResponse, error) { - return debugBreakStatusResponse{Id: id, Status: debugBreakStatusArmed, IsArmed: true}, nil + return debugBreakStatusResponse{Id: id, Status: debugBreakStatusEnabled, IsEnabled: true}, nil } var stdout bytes.Buffer @@ -181,7 +271,7 @@ func TestRunDebugBreakStatusReturnsCurrentStatus(t *testing.T) { if err := json.Unmarshal(stdout.Bytes(), &response); err != nil { t.Fatalf("stdout is not valid JSON: %v\n%s", err, stdout.String()) } - if response.Status != debugBreakStatusArmed { + if response.Status != debugBreakStatusEnabled { t.Fatalf("status mismatch: %#v", response) } } @@ -197,3 +287,13 @@ func TestParseDebugBreakStatusOptionsRequiresID(t *testing.T) { t.Fatalf("error mismatch: %v", err) } } + +func parseDebugBreakErrorEnvelope(t *testing.T, payload []byte) cliErrorEnvelope { + t.Helper() + + var envelope cliErrorEnvelope + if err := json.Unmarshal(payload, &envelope); err != nil { + t.Fatalf("stderr is not valid JSON: %v\n%s", err, string(payload)) + } + return envelope +} diff --git a/cli/internal/cli/error_envelope.go b/cli/internal/cli/error_envelope.go index eac5ae0fc8..67574bde45 100644 --- a/cli/internal/cli/error_envelope.go +++ b/cli/internal/cli/error_envelope.go @@ -23,7 +23,7 @@ const ( errorCodeCLIUpdateRequired = "CLI_UPDATE_REQUIRED" errorCodeCompileWaitTimeout = "COMPILE_WAIT_TIMEOUT" errorCodeControlPlayModeWaitTimeout = "CONTROL_PLAY_MODE_WAIT_TIMEOUT" - errorCodeDebugBreakNotArmed = "DEBUG_BREAK_NOT_ARMED" + errorCodeDebugBreakNotEnabled = "DEBUG_BREAK_NOT_ENABLED" errorCodeDebugBreakWaitTimeout = "DEBUG_BREAK_WAIT_TIMEOUT" errorCodeDebugBreakExpired = "DEBUG_BREAK_EXPIRED" errorCodeDebugBreakCleared = "DEBUG_BREAK_CLEARED" From 8a57e63f908d41ede6addca7cba0625270ebb2d2 Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 3 Jun 2026 21:47:27 +0900 Subject: [PATCH 11/26] Clarify debug break diagnostics Rename the wait status elapsed field to describe that it is measured since enable-debug-break, and include marker id/count when keyboard or mouse simulation is interrupted by a UnityCliLoopDebug.Break marker. This keeps input interruption responses actionable without mixing log or scene inspection responsibilities into the wait command. --- .../skills/uloop-simulate-keyboard/SKILL.md | 4 +- .../uloop-simulate-mouse-input/SKILL.md | 4 +- .../uloop-wait-for-debug-break/SKILL.md | 2 +- Assets/Tests/Editor/PausePointTests.cs | 39 ++++++++++++++ .../Tests/PlayMode/SimulateKeyboardTests.cs | 53 +++++++++++++++++++ .../Tests/PlayMode/SimulateMouseInputTests.cs | 52 ++++++++++++++++++ .../UnityCLILoop.Tests.PlayMode.asmdef | 1 + .../CliOnlyTools~/PausePoint/Skill/SKILL.md | 2 +- .../UnityCliLoopInputSimulationTypes.cs | 4 ++ .../PausePoint/PausePointTools.cs | 4 +- .../SimulateKeyboardResponse.cs | 2 + .../SimulateKeyboard/SimulateKeyboardTool.cs | 2 + .../SimulateKeyboardUseCase.cs | 35 +++++++++++- .../SimulateKeyboard/Skill/SKILL.md | 4 +- ...tPartyTools.SimulateKeyboard.Editor.asmdef | 1 + .../SimulateMouseInputResponse.cs | 2 + .../SimulateMouseInputTool.cs | 2 + .../SimulateMouseInputUseCase.cs | 35 +++++++++++- .../SimulateMouseInput/Skill/SKILL.md | 4 +- ...artyTools.SimulateMouseInput.Editor.asmdef | 1 + .../Api/PausePointStatusBridgeCommand.cs | 4 +- .../src/Runtime/PausePoints/AssemblyInfo.cs | 3 ++ .../PausePoints/UloopPausePointRegistry.cs | 38 +++++++++++-- cli/internal/cli/debug_break_wait.go | 42 +++++++-------- cli/internal/cli/debug_break_wait_test.go | 31 ++++++----- 25 files changed, 321 insertions(+), 50 deletions(-) diff --git a/.agents/skills/uloop-simulate-keyboard/SKILL.md b/.agents/skills/uloop-simulate-keyboard/SKILL.md index 0614d39c0c..fc65968875 100644 --- a/.agents/skills/uloop-simulate-keyboard/SKILL.md +++ b/.agents/skills/uloop-simulate-keyboard/SKILL.md @@ -48,7 +48,7 @@ Use `KeyDown` / `KeyUp` when the scenario intentionally needs a held key. - Use `UnityCliLoopDebug.Break("")` when a screenshot cannot prove that the keyboard input changed gameplay state, such as jump, sprint, or interaction. - Put the marker at a natural state transition after the game consumed the key, such as after jump velocity is applied, not immediately after sending `simulate-keyboard`. -- If the response has `InterruptedByDebugBreak: true`, Unity is paused for inspection and the tool released its held input bookkeeping. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. +- If the response has `InterruptedByDebugBreak: true`, Unity is paused for inspection and the tool released its held input bookkeeping. If a `UnityCliLoopDebug.Break` marker caused the pause, `DebugBreakId` and `DebugBreakHitCount` identify it. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. - Use distinct marker ids for strict phases, for example `jump-key-read` and `jump-velocity-applied`. ### KeyDown/KeyUp Rules @@ -93,6 +93,8 @@ Returns JSON with: - `Action` (string): The `--action` value that was applied (`Press`, `KeyDown`, or `KeyUp`) - `KeyName` (string, nullable): The key that was acted on; may be `null` when the action could not resolve a key - `InterruptedByDebugBreak` (boolean): True when Unity paused during Debug Break inspection and the input bookkeeping was safely released +- `DebugBreakId` (string, nullable): The marker id when a `UnityCliLoopDebug.Break` marker caused the interruption +- `DebugBreakHitCount` (integer, nullable): The marker hit count when a `UnityCliLoopDebug.Break` marker caused the interruption ## Prerequisites diff --git a/.agents/skills/uloop-simulate-mouse-input/SKILL.md b/.agents/skills/uloop-simulate-mouse-input/SKILL.md index 9e9b2f5357..78b3f0fa8c 100644 --- a/.agents/skills/uloop-simulate-mouse-input/SKILL.md +++ b/.agents/skills/uloop-simulate-mouse-input/SKILL.md @@ -52,7 +52,7 @@ uloop simulate-mouse-input --action [options] - Use `UnityCliLoopDebug.Break("")` when a screenshot cannot prove that mouse input changed gameplay state, such as block hit, camera turn, or item placement. - Put the marker at a natural state transition after the game consumed the mouse input, such as after a raycast hit, damage application, placement, or camera rotation update, not immediately after sending `simulate-mouse-input`. -- If the response has `InterruptedByDebugBreak: true`, Unity is paused for inspection and the tool released its held input bookkeeping. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. +- If the response has `InterruptedByDebugBreak: true`, Unity is paused for inspection and the tool released its held input bookkeeping. If a `UnityCliLoopDebug.Break` marker caused the pause, `DebugBreakId` and `DebugBreakHitCount` identify it. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. - Use distinct marker ids for strict phases, for example `block-raycast-hit` and `block-damage-applied`. ### Global Options (optional) @@ -115,5 +115,7 @@ Returns JSON with: - `PositionX`: Target X coordinate (nullable float; populated for `Click` / `LongPress`) - `PositionY`: Target Y coordinate (nullable float; populated for `Click` / `LongPress`) - `InterruptedByDebugBreak`: True when Unity paused during Debug Break inspection and the input bookkeeping was safely released +- `DebugBreakId`: The marker id when a `UnityCliLoopDebug.Break` marker caused the interruption +- `DebugBreakHitCount`: The marker hit count when a `UnityCliLoopDebug.Break` marker caused the interruption There is no `DeltaX`, `DeltaY`, `ScrollX`, `ScrollY`, `Duration`, or hit-element field in the response — only the issued action, button, target position, and Debug Break interruption state are echoed back. Verify visual outcome with a follow-up screenshot. diff --git a/.agents/skills/uloop-wait-for-debug-break/SKILL.md b/.agents/skills/uloop-wait-for-debug-break/SKILL.md index 97b4ec7730..e2fdbb5a9b 100644 --- a/.agents/skills/uloop-wait-for-debug-break/SKILL.md +++ b/.agents/skills/uloop-wait-for-debug-break/SKILL.md @@ -43,7 +43,7 @@ uloop debug-break-status --id player-jumped uloop wait-for-debug-break --id player-jumped --timeout-seconds 30 ``` -If this command times out, the marker line was not reached while the command waited. Inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, and `remainingMilliseconds` to distinguish input not being consumed, gameplay conditions not being met, an id mismatch, or Unity already being paused. +If this command times out, the marker line was not reached while the command waited. Inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, gameplay conditions not being met, an id mismatch, or Unity already being paused. `elapsedSinceEnabledMilliseconds` is measured from `enable-debug-break`, not from `wait-for-debug-break`. 7. While Unity is paused, inspect state with `uloop get-logs`, `uloop get-hierarchy`, `uloop find-game-objects`, screenshots, or `uloop execute-dynamic-code`. 8. Clear the marker if you stop waiting: diff --git a/Assets/Tests/Editor/PausePointTests.cs b/Assets/Tests/Editor/PausePointTests.cs index a52f5aa111..9d10457e32 100644 --- a/Assets/Tests/Editor/PausePointTests.cs +++ b/Assets/Tests/Editor/PausePointTests.cs @@ -70,6 +70,20 @@ public void Break_WhenPausePointIsEnabled_RecordsHitAndRequestsPause() Assert.That(snapshot.HitCount, Is.EqualTo(1)); } + [Test] + public void Break_WhenPausePointIsEnabled_StoresLatestHitSnapshot() + { + // Verifies input interruption responses can read the latest marker hit. + UloopPausePointRegistry.Enable("jump", 30); + + UnityCliLoopDebug.Break("jump"); + + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetLatestHitSnapshot(); + Assert.That(snapshot, Is.Not.Null); + Assert.That(snapshot.Id, Is.EqualTo("jump")); + Assert.That(snapshot.HitCount, Is.EqualTo(1)); + } + [Test] public void GetStatus_WhenTimeoutPasses_ExpiresAndDisarms() { @@ -85,6 +99,18 @@ public void GetStatus_WhenTimeoutPasses_ExpiresAndDisarms() Assert.That(_pauseController.PauseCount, Is.EqualTo(0)); } + [Test] + public void GetStatus_WhenEnabled_ReportsElapsedSinceEnabledMilliseconds() + { + // Verifies elapsed time is named as time since the marker was enabled. + UloopPausePointRegistry.Enable("jump", 30); + _nowUtc = _nowUtc.AddMilliseconds(250); + + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("jump"); + + Assert.That(snapshot.ElapsedSinceEnabledMilliseconds, Is.EqualTo(250)); + } + [Test] public void Clear_WhenPausePointIsEnabled_DisablesWithoutPause() { @@ -99,6 +125,18 @@ public void Clear_WhenPausePointIsEnabled_DisablesWithoutPause() Assert.That(_pauseController.PauseCount, Is.EqualTo(0)); } + [Test] + public void Enable_WhenSamePausePointWasHit_ClearsLatestHitSnapshot() + { + // Verifies re-enabling a marker does not leave stale hit details for input tools. + UloopPausePointRegistry.Enable("jump", 30); + UnityCliLoopDebug.Break("jump"); + + UloopPausePointRegistry.Enable("jump", 30); + + Assert.That(UloopPausePointRegistry.GetLatestHitSnapshot(), Is.Null); + } + [Test] public void ClearAll_WhenPausePointWasHit_ClearsTerminalStatus() { @@ -112,6 +150,7 @@ public void ClearAll_WhenPausePointWasHit_ClearsTerminalStatus() Assert.That(result.ClearedCount, Is.EqualTo(1)); Assert.That(snapshot.Status, Is.EqualTo(UloopPausePointStatus.Cleared)); Assert.That(snapshot.IsHit, Is.False); + Assert.That(UloopPausePointRegistry.GetLatestHitSnapshot(), Is.Null); } [Test] diff --git a/Assets/Tests/PlayMode/SimulateKeyboardTests.cs b/Assets/Tests/PlayMode/SimulateKeyboardTests.cs index 714f212e9a..a749b0abc3 100644 --- a/Assets/Tests/PlayMode/SimulateKeyboardTests.cs +++ b/Assets/Tests/PlayMode/SimulateKeyboardTests.cs @@ -1,5 +1,6 @@ #if ULOOP_HAS_INPUT_SYSTEM #nullable enable +using System; using System.Collections; using System.Threading; using System.Threading.Tasks; @@ -14,6 +15,7 @@ using UnityEngine.InputSystem.LowLevel; using UnityEngine.UI; using UnityEngine.TestTools; +using Object = UnityEngine.Object; namespace io.github.hatayama.UnityCliLoop.Tests.PlayMode { @@ -60,6 +62,7 @@ public override void Setup() public override void TearDown() { InputSystemUpdateHelper.ResetPauseProviderForTests(); + UloopPausePointRegistry.ResetForTests(); InputSettings settings = RequireInputSettings(); settings.updateMode = originalUpdateMode; Time.timeScale = originalTimeScale; @@ -140,9 +143,45 @@ public IEnumerator Press_WhenUnityPausesDuringObservation_Should_CompleteAsDebug Assert.IsTrue(lastResponse.InterruptedByDebugBreak); Assert.AreEqual("Press", lastResponse.Action); Assert.AreEqual("Space", lastResponse.KeyName); + Assert.IsNull(lastResponse.DebugBreakId); + Assert.IsNull(lastResponse.DebugBreakHitCount); Assert.IsFalse(SimulateKeyboardOverlayState.IsActive, "Debug-break interruption should clear keyboard overlay state."); } + [UnityTest] + public IEnumerator Press_WhenDebugBreakMarkerHits_Should_ReturnMarkerDetails() + { + // Verifies marker-caused interruption reports the marker id and hit count. + yield return null; + + UloopPausePointRegistry.ConfigureForTests( + new FakePausePointPauseController(), + () => new DateTime(2026, 6, 3, 0, 0, 0, DateTimeKind.Utc)); + UloopPausePointRegistry.Enable("space-press", 30); + SimulateKeyboardSchema parameters = new() + { + Action = UnityCliLoopKeyboardAction.Press, + Key = "Space", + Duration = 1f + }; + Task task = + tool.ExecuteWithCancellationAsync(parameters, CancellationToken.None); + + yield return new WaitUntil(() => keyboard[Key.Space].isPressed || task.IsCompleted); + Assert.IsFalse(task.IsCompleted, "The test must pause during the press observation window."); + + UnityCliLoopDebug.Break("space-press"); + InputSystemUpdateHelper.ConfigurePauseProviderForTests(() => true); + yield return WaitForTask(task); + InputSystemUpdateHelper.ResetPauseProviderForTests(); + + lastResponse = task.Result; + Assert.IsTrue(lastResponse.Success); + Assert.IsTrue(lastResponse.InterruptedByDebugBreak); + Assert.AreEqual("space-press", lastResponse.DebugBreakId); + Assert.AreEqual(1, lastResponse.DebugBreakHitCount); + } + [UnityTest] public IEnumerator Press_Space_Should_SetWasPressedThisFrame() { @@ -742,6 +781,20 @@ private static InputSettings RequireInputSettings() return settings!; } + /// + /// Records pause requests without pausing the real Unity Editor. + /// + private sealed class FakePausePointPauseController : IUloopPausePointPauseController + { + public bool IsPlaying => true; + public bool IsPaused { get; private set; } + + public void Pause() + { + IsPaused = true; + } + } + private static BadgeVisual RequireBadgeVisual(string keyName) { InputVisualizationCanvas canvas = Object.FindAnyObjectByType(); diff --git a/Assets/Tests/PlayMode/SimulateMouseInputTests.cs b/Assets/Tests/PlayMode/SimulateMouseInputTests.cs index 035e4371e4..a412dea463 100644 --- a/Assets/Tests/PlayMode/SimulateMouseInputTests.cs +++ b/Assets/Tests/PlayMode/SimulateMouseInputTests.cs @@ -1,5 +1,6 @@ #if ULOOP_HAS_INPUT_SYSTEM #nullable enable +using System; using System.Collections; using System.Threading.Tasks; using io.github.hatayama.UnityCliLoop.FirstPartyTools; @@ -10,6 +11,7 @@ using UnityEngine; using UnityEngine.InputSystem; using UnityEngine.TestTools; +using Object = UnityEngine.Object; namespace io.github.hatayama.UnityCliLoop.Tests.PlayMode { @@ -36,6 +38,7 @@ public override void Setup() public override void TearDown() { InputSystemUpdateHelper.ResetPauseProviderForTests(); + UloopPausePointRegistry.ResetForTests(); MouseInputState.ReleaseAllButtons(); Object.DestroyImmediate(mouseObserverGo); base.TearDown(); @@ -128,9 +131,44 @@ public IEnumerator Click_WhenUnityPausesDuringObservation_Should_CompleteAsDebug Assert.IsTrue(lastResponse.InterruptedByDebugBreak); Assert.AreEqual("Click", lastResponse.Action); Assert.AreEqual("Left", lastResponse.Button); + Assert.IsNull(lastResponse.DebugBreakId); + Assert.IsNull(lastResponse.DebugBreakHitCount); Assert.IsFalse(SimulateMouseInputOverlayState.HasAnyActivity, "Debug-break interruption should clear mouse overlay state."); } + [UnityTest] + public IEnumerator Click_WhenDebugBreakMarkerHits_Should_ReturnMarkerDetails() + { + // Verifies marker-caused interruption reports the marker id and hit count. + yield return null; + + UloopPausePointRegistry.ConfigureForTests( + new FakePausePointPauseController(), + () => new DateTime(2026, 6, 3, 0, 0, 0, DateTimeKind.Utc)); + UloopPausePointRegistry.Enable("left-click", 30); + Task task = tool.ExecuteAsync(new JObject + { + ["action"] = MouseInputAction.Click.ToString(), + ["x"] = 400, + ["y"] = 300, + ["duration"] = 1f + }, System.Threading.CancellationToken.None); + + yield return new WaitUntil(() => mouse.leftButton.isPressed || task.IsCompleted); + Assert.IsFalse(task.IsCompleted, "The test must pause during the click observation window."); + + UnityCliLoopDebug.Break("left-click"); + InputSystemUpdateHelper.ConfigurePauseProviderForTests(() => true); + yield return WaitForTask(task); + InputSystemUpdateHelper.ResetPauseProviderForTests(); + + lastResponse = (SimulateMouseInputResponse)task.Result; + Assert.IsTrue(lastResponse.Success); + Assert.IsTrue(lastResponse.InterruptedByDebugBreak); + Assert.AreEqual("left-click", lastResponse.DebugBreakId); + Assert.AreEqual(1, lastResponse.DebugBreakHitCount); + } + #endregion #region LongPress Tests @@ -289,6 +327,20 @@ private static IEnumerator WaitForTask(Task task) Assert.IsFalse(task.IsFaulted, $"Tool execution should not fault: {task.Exception}"); } + /// + /// Records pause requests without pausing the real Unity Editor. + /// + private sealed class FakePausePointPauseController : IUloopPausePointPauseController + { + public bool IsPlaying => true; + public bool IsPaused { get; private set; } + + public void Pause() + { + IsPaused = true; + } + } + #endregion } diff --git a/Assets/Tests/PlayMode/UnityCLILoop.Tests.PlayMode.asmdef b/Assets/Tests/PlayMode/UnityCLILoop.Tests.PlayMode.asmdef index fd43ec1da1..02021ece61 100644 --- a/Assets/Tests/PlayMode/UnityCLILoop.Tests.PlayMode.asmdef +++ b/Assets/Tests/PlayMode/UnityCLILoop.Tests.PlayMode.asmdef @@ -12,6 +12,7 @@ "GUID:b21158f16291043699bc11600a45a9ee", "GUID:f1a38fcd7e85047c5a86331a3e37ddfc", "GUID:45d3617c96fa64d3e95383450e67f25a", + "GUID:527f26a36b5043c2bd4d4036d04cd76d", "GUID:a2d87883023de4cf59b7d1962d5dd8aa", "GUID:aa7cf56cc5f074e57ba35272b877e116", "GUID:dc412b2cd951d452b8dcec99c3adeb19", diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md index 97b4ec7730..e2fdbb5a9b 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -43,7 +43,7 @@ uloop debug-break-status --id player-jumped uloop wait-for-debug-break --id player-jumped --timeout-seconds 30 ``` -If this command times out, the marker line was not reached while the command waited. Inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, and `remainingMilliseconds` to distinguish input not being consumed, gameplay conditions not being met, an id mismatch, or Unity already being paused. +If this command times out, the marker line was not reached while the command waited. Inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, gameplay conditions not being met, an id mismatch, or Unity already being paused. `elapsedSinceEnabledMilliseconds` is measured from `enable-debug-break`, not from `wait-for-debug-break`. 7. While Unity is paused, inspect state with `uloop get-logs`, `uloop get-hierarchy`, `uloop find-game-objects`, screenshots, or `uloop execute-dynamic-code`. 8. Clear the marker if you stop waiting: diff --git a/Packages/src/Editor/FirstPartyTools/Common/InputSimulation/UnityCliLoopInputSimulationTypes.cs b/Packages/src/Editor/FirstPartyTools/Common/InputSimulation/UnityCliLoopInputSimulationTypes.cs index b9d43761cd..4bd14d6cd0 100644 --- a/Packages/src/Editor/FirstPartyTools/Common/InputSimulation/UnityCliLoopInputSimulationTypes.cs +++ b/Packages/src/Editor/FirstPartyTools/Common/InputSimulation/UnityCliLoopInputSimulationTypes.cs @@ -96,6 +96,8 @@ public sealed class UnityCliLoopKeyboardSimulationResult public string Action { get; set; } = ""; public string? KeyName { get; set; } public bool InterruptedByDebugBreak { get; set; } + public string? DebugBreakId { get; set; } + public int? DebugBreakHitCount { get; set; } } /// @@ -126,6 +128,8 @@ public sealed class UnityCliLoopMouseInputSimulationResult public float? PositionX { get; set; } public float? PositionY { get; set; } public bool InterruptedByDebugBreak { get; set; } + public string? DebugBreakId { get; set; } + public int? DebugBreakHitCount { get; set; } } /// diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs index 9edb8664a2..e914012996 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs @@ -39,7 +39,7 @@ public class PausePointResponse : UnityCliLoopToolResponse public bool IsHit { get; set; } public int HitCount { get; set; } public int TimeoutSeconds { get; set; } - public long ElapsedMilliseconds { get; set; } + public long ElapsedSinceEnabledMilliseconds { get; set; } public bool IsPlaying { get; set; } public bool IsPaused { get; set; } public int ClearedCount { get; set; } @@ -61,7 +61,7 @@ internal static PausePointResponse FromSnapshot(UloopPausePointSnapshot snapshot IsHit = snapshot.IsHit, HitCount = snapshot.HitCount, TimeoutSeconds = snapshot.TimeoutSeconds, - ElapsedMilliseconds = snapshot.ElapsedMilliseconds, + ElapsedSinceEnabledMilliseconds = snapshot.ElapsedSinceEnabledMilliseconds, IsPlaying = snapshot.IsPlaying, IsPaused = snapshot.IsPaused, Message = snapshot.Message diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs index c1c2bfe2bc..6d52278470 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs @@ -14,6 +14,8 @@ public class SimulateKeyboardResponse : UnityCliLoopToolResponse public string Action { get; set; } = ""; public string? KeyName { get; set; } public bool InterruptedByDebugBreak { get; set; } + public string? DebugBreakId { get; set; } + public int? DebugBreakHitCount { get; set; } public SimulateKeyboardResponse() { diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardTool.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardTool.cs index 1f87f8d24b..b4420e1016 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardTool.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardTool.cs @@ -49,6 +49,8 @@ private static SimulateKeyboardResponse ToResponse(UnityCliLoopKeyboardSimulatio Action = result.Action, KeyName = result.KeyName, InterruptedByDebugBreak = result.InterruptedByDebugBreak, + DebugBreakId = result.DebugBreakId, + DebugBreakHitCount = result.DebugBreakHitCount, }; } } diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs index add902f916..e6526d83a8 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs @@ -92,6 +92,8 @@ public async Task SimulateKeyboardAsync( }; } + UloopPausePointRegistry.ClearLatestHitSnapshot(); + VibeLogger.LogInfo( "simulate_keyboard_start", "Keyboard simulation started", @@ -315,7 +317,7 @@ private static UnityCliLoopKeyboardSimulationResult InterruptedKeyResult( UnityCliLoopKeyboardAction action, string keyName) { - return new UnityCliLoopKeyboardSimulationResult + UnityCliLoopKeyboardSimulationResult result = new() { Success = true, Message = $"Keyboard input stopped because Unity paused during Debug Break inspection. Key '{keyName}' was released from Unity CLI Loop bookkeeping.", @@ -323,6 +325,37 @@ private static UnityCliLoopKeyboardSimulationResult InterruptedKeyResult( KeyName = keyName, InterruptedByDebugBreak = true }; + AttachDebugBreakHit(result); + return result; + } + + private static void AttachDebugBreakHit(UnityCliLoopKeyboardSimulationResult result) + { + if (result == null) + { + Debug.Assert(false, "result must not be null"); + return; + } + + UloopPausePointSnapshot? snapshot = UloopPausePointRegistry.GetLatestHitSnapshot(); + if (snapshot == null) + { + return; + } + + if (!snapshot.IsHit) + { + return; + } + + string? snapshotId = snapshot.Id; + if (string.IsNullOrEmpty(snapshotId)) + { + return; + } + + result.DebugBreakId = snapshotId; + result.DebugBreakHitCount = snapshot.HitCount; } private static async Task FinalizePressOverlay(CancellationToken ct) diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md index 0614d39c0c..fc65968875 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md @@ -48,7 +48,7 @@ Use `KeyDown` / `KeyUp` when the scenario intentionally needs a held key. - Use `UnityCliLoopDebug.Break("")` when a screenshot cannot prove that the keyboard input changed gameplay state, such as jump, sprint, or interaction. - Put the marker at a natural state transition after the game consumed the key, such as after jump velocity is applied, not immediately after sending `simulate-keyboard`. -- If the response has `InterruptedByDebugBreak: true`, Unity is paused for inspection and the tool released its held input bookkeeping. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. +- If the response has `InterruptedByDebugBreak: true`, Unity is paused for inspection and the tool released its held input bookkeeping. If a `UnityCliLoopDebug.Break` marker caused the pause, `DebugBreakId` and `DebugBreakHitCount` identify it. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. - Use distinct marker ids for strict phases, for example `jump-key-read` and `jump-velocity-applied`. ### KeyDown/KeyUp Rules @@ -93,6 +93,8 @@ Returns JSON with: - `Action` (string): The `--action` value that was applied (`Press`, `KeyDown`, or `KeyUp`) - `KeyName` (string, nullable): The key that was acted on; may be `null` when the action could not resolve a key - `InterruptedByDebugBreak` (boolean): True when Unity paused during Debug Break inspection and the input bookkeeping was safely released +- `DebugBreakId` (string, nullable): The marker id when a `UnityCliLoopDebug.Break` marker caused the interruption +- `DebugBreakHitCount` (integer, nullable): The marker hit count when a `UnityCliLoopDebug.Break` marker caused the interruption ## Prerequisites diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/UnityCLILoop.FirstPartyTools.SimulateKeyboard.Editor.asmdef b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/UnityCLILoop.FirstPartyTools.SimulateKeyboard.Editor.asmdef index 1d95197a8a..655fedc003 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/UnityCLILoop.FirstPartyTools.SimulateKeyboard.Editor.asmdef +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/UnityCLILoop.FirstPartyTools.SimulateKeyboard.Editor.asmdef @@ -5,6 +5,7 @@ "GUID:fc3fd32eddbee40e39c2d76dc184957b", "GUID:f1a38fcd7e85047c5a86331a3e37ddfc", "GUID:45d3617c96fa64d3e95383450e67f25a", + "GUID:527f26a36b5043c2bd4d4036d04cd76d", "GUID:aa7cf56cc5f074e57ba35272b877e116", "GUID:c956a21f824994ef087b6de566690b3d", "GUID:75469ad4d38634e559750d17036d5f7c" diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputResponse.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputResponse.cs index 1add057342..673a755e84 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputResponse.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputResponse.cs @@ -16,6 +16,8 @@ public class SimulateMouseInputResponse : UnityCliLoopToolResponse public float? PositionX { get; set; } public float? PositionY { get; set; } public bool InterruptedByDebugBreak { get; set; } + public string? DebugBreakId { get; set; } + public int? DebugBreakHitCount { get; set; } public SimulateMouseInputResponse() { diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputTool.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputTool.cs index e9f1691ae7..d97a39a1e2 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputTool.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputTool.cs @@ -58,6 +58,8 @@ private static SimulateMouseInputResponse ToResponse(UnityCliLoopMouseInputSimul PositionX = result.PositionX, PositionY = result.PositionY, InterruptedByDebugBreak = result.InterruptedByDebugBreak, + DebugBreakId = result.DebugBreakId, + DebugBreakHitCount = result.DebugBreakHitCount, }; } } diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs index 9064cf05a3..1bd94dd69e 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs @@ -92,6 +92,8 @@ public async Task SimulateMouseInputAsyn }; } + UloopPausePointRegistry.ClearLatestHitSnapshot(); + VibeLogger.LogInfo( "simulate_mouse_input_start", "Mouse input simulation started", @@ -441,13 +443,44 @@ private static UnityCliLoopMouseInputSimulationResult InterruptedButtonResult( private static UnityCliLoopMouseInputSimulationResult InterruptedActionResult( UnityCliLoopMouseInputAction action) { - return new UnityCliLoopMouseInputSimulationResult + UnityCliLoopMouseInputSimulationResult result = new() { Success = true, Message = "Mouse input stopped because Unity paused during Debug Break inspection. Unity CLI Loop released its held input bookkeeping.", Action = action.ToString(), InterruptedByDebugBreak = true }; + AttachDebugBreakHit(result); + return result; + } + + private static void AttachDebugBreakHit(UnityCliLoopMouseInputSimulationResult result) + { + if (result == null) + { + Debug.Assert(false, "result must not be null"); + return; + } + + UloopPausePointSnapshot? snapshot = UloopPausePointRegistry.GetLatestHitSnapshot(); + if (snapshot == null) + { + return; + } + + if (!snapshot.IsHit) + { + return; + } + + string? snapshotId = snapshot.Id; + if (string.IsNullOrEmpty(snapshotId)) + { + return; + } + + result.DebugBreakId = snapshotId; + result.DebugBreakHitCount = snapshot.HitCount; } private static async Task ReleaseButtonIfPossible(Mouse mouse, RuntimeMouseButton button) diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md index 9e9b2f5357..78b3f0fa8c 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md @@ -52,7 +52,7 @@ uloop simulate-mouse-input --action [options] - Use `UnityCliLoopDebug.Break("")` when a screenshot cannot prove that mouse input changed gameplay state, such as block hit, camera turn, or item placement. - Put the marker at a natural state transition after the game consumed the mouse input, such as after a raycast hit, damage application, placement, or camera rotation update, not immediately after sending `simulate-mouse-input`. -- If the response has `InterruptedByDebugBreak: true`, Unity is paused for inspection and the tool released its held input bookkeeping. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. +- If the response has `InterruptedByDebugBreak: true`, Unity is paused for inspection and the tool released its held input bookkeeping. If a `UnityCliLoopDebug.Break` marker caused the pause, `DebugBreakId` and `DebugBreakHitCount` identify it. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. - Use distinct marker ids for strict phases, for example `block-raycast-hit` and `block-damage-applied`. ### Global Options (optional) @@ -115,5 +115,7 @@ Returns JSON with: - `PositionX`: Target X coordinate (nullable float; populated for `Click` / `LongPress`) - `PositionY`: Target Y coordinate (nullable float; populated for `Click` / `LongPress`) - `InterruptedByDebugBreak`: True when Unity paused during Debug Break inspection and the input bookkeeping was safely released +- `DebugBreakId`: The marker id when a `UnityCliLoopDebug.Break` marker caused the interruption +- `DebugBreakHitCount`: The marker hit count when a `UnityCliLoopDebug.Break` marker caused the interruption There is no `DeltaX`, `DeltaY`, `ScrollX`, `ScrollY`, `Duration`, or hit-element field in the response — only the issued action, button, target position, and Debug Break interruption state are echoed back. Verify visual outcome with a follow-up screenshot. diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/UnityCLILoop.FirstPartyTools.SimulateMouseInput.Editor.asmdef b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/UnityCLILoop.FirstPartyTools.SimulateMouseInput.Editor.asmdef index 27e6e0e652..83b30c5ae9 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/UnityCLILoop.FirstPartyTools.SimulateMouseInput.Editor.asmdef +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/UnityCLILoop.FirstPartyTools.SimulateMouseInput.Editor.asmdef @@ -5,6 +5,7 @@ "GUID:fc3fd32eddbee40e39c2d76dc184957b", "GUID:f1a38fcd7e85047c5a86331a3e37ddfc", "GUID:45d3617c96fa64d3e95383450e67f25a", + "GUID:527f26a36b5043c2bd4d4036d04cd76d", "GUID:aa7cf56cc5f074e57ba35272b877e116", "GUID:c956a21f824994ef087b6de566690b3d", "GUID:75469ad4d38634e559750d17036d5f7c" diff --git a/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs b/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs index 5f547e34f1..6c1000cda4 100644 --- a/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs +++ b/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs @@ -50,7 +50,7 @@ public class PausePointStatusResponse : UnityCliLoopToolResponse public bool IsHit { get; set; } public int HitCount { get; set; } public int TimeoutSeconds { get; set; } - public long ElapsedMilliseconds { get; set; } + public long ElapsedSinceEnabledMilliseconds { get; set; } public bool IsPlaying { get; set; } public bool IsPaused { get; set; } public string Message { get; set; } = string.Empty; @@ -70,7 +70,7 @@ internal static PausePointStatusResponse FromSnapshot(UloopPausePointSnapshot sn IsHit = snapshot.IsHit, HitCount = snapshot.HitCount, TimeoutSeconds = snapshot.TimeoutSeconds, - ElapsedMilliseconds = snapshot.ElapsedMilliseconds, + ElapsedSinceEnabledMilliseconds = snapshot.ElapsedSinceEnabledMilliseconds, IsPlaying = snapshot.IsPlaying, IsPaused = snapshot.IsPaused, Message = snapshot.Message diff --git a/Packages/src/Runtime/PausePoints/AssemblyInfo.cs b/Packages/src/Runtime/PausePoints/AssemblyInfo.cs index 5e88fa82ec..0fe20cf7d3 100644 --- a/Packages/src/Runtime/PausePoints/AssemblyInfo.cs +++ b/Packages/src/Runtime/PausePoints/AssemblyInfo.cs @@ -1,5 +1,8 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.PausePoint.Editor")] +[assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.SimulateKeyboard.Editor")] +[assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.SimulateMouseInput.Editor")] [assembly: InternalsVisibleTo("UnityCLILoop.Infrastructure")] [assembly: InternalsVisibleTo("UnityCLILoop.Tests.Editor")] +[assembly: InternalsVisibleTo("UnityCLILoop.Tests.PlayMode")] diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs index a2a1bd63f1..cd650f59c3 100644 --- a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs +++ b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs @@ -16,6 +16,7 @@ internal static class UloopPausePointRegistry private static readonly Dictionary Entries = new(); private static IUloopPausePointPauseController _pauseController = new UnityEditorPausePointPauseController(); private static Func _nowProvider = () => DateTime.UtcNow; + private static UloopPausePointSnapshot _latestHitSnapshot; public static UloopPausePointSnapshot Enable(string id, int timeoutSeconds) { @@ -25,6 +26,7 @@ public static UloopPausePointSnapshot Enable(string id, int timeoutSeconds) DateTime now = NowUtc(); UloopPausePointEntry entry = new(id, timeoutSeconds, now); Entries[id] = entry; + ClearLatestHitSnapshotIfMatches(id); return entry.ToSnapshot(now, _pauseController); } @@ -40,6 +42,7 @@ public static UloopPausePointSnapshot Clear(string id) UloopPausePointEntry entry = Entries[id]; entry.MarkCleared(); + ClearLatestHitSnapshotIfMatches(id); return entry.ToSnapshot(now, _pauseController); } @@ -57,6 +60,7 @@ public static UloopPausePointClearAllResult ClearAll() entry.MarkCleared(); clearedCount++; } + ClearLatestHitSnapshot(); return new UloopPausePointClearAllResult(clearedCount, now); } @@ -99,7 +103,34 @@ public static UloopPausePointSnapshot Hit(string id) _pauseController.Pause(); entry.RecordHit(now, _pauseController.IsPlaying, _pauseController.IsPaused); - return entry.ToSnapshot(now, _pauseController); + UloopPausePointSnapshot snapshot = entry.ToSnapshot(now, _pauseController); + _latestHitSnapshot = snapshot; + return snapshot; + } + + public static UloopPausePointSnapshot GetLatestHitSnapshot() + { + return _latestHitSnapshot; + } + + public static void ClearLatestHitSnapshot() + { + _latestHitSnapshot = null; + } + + private static void ClearLatestHitSnapshotIfMatches(string id) + { + if (_latestHitSnapshot == null) + { + return; + } + + if (_latestHitSnapshot.Id != id) + { + return; + } + + _latestHitSnapshot = null; } public static void ConfigureForTests(IUloopPausePointPauseController pauseController, Func nowProvider) @@ -114,6 +145,7 @@ public static void ConfigureForTests(IUloopPausePointPauseController pauseContro public static void ResetForTests() { Entries.Clear(); + _latestHitSnapshot = null; _pauseController = new UnityEditorPausePointPauseController(); _nowProvider = () => DateTime.UtcNow; } @@ -172,7 +204,7 @@ public UloopPausePointSnapshot( IsHit = isHit; HitCount = hitCount; TimeoutSeconds = timeoutSeconds; - ElapsedMilliseconds = elapsedMilliseconds; + ElapsedSinceEnabledMilliseconds = elapsedMilliseconds; IsPlaying = isPlaying; IsPaused = isPaused; Message = message ?? string.Empty; @@ -184,7 +216,7 @@ public UloopPausePointSnapshot( public bool IsHit { get; } public int HitCount { get; } public int TimeoutSeconds { get; } - public long ElapsedMilliseconds { get; } + public long ElapsedSinceEnabledMilliseconds { get; } public bool IsPlaying { get; } public bool IsPaused { get; } public string Message { get; } diff --git a/cli/internal/cli/debug_break_wait.go b/cli/internal/cli/debug_break_wait.go index 48ebd8e99e..6c636c33ed 100644 --- a/cli/internal/cli/debug_break_wait.go +++ b/cli/internal/cli/debug_break_wait.go @@ -44,16 +44,16 @@ type debugBreakStatusOptions struct { } type debugBreakStatusResponse struct { - Id string `json:"Id"` - Status string `json:"Status"` - IsEnabled bool `json:"IsEnabled"` - IsHit bool `json:"IsHit"` - HitCount int `json:"HitCount"` - TimeoutSeconds int `json:"TimeoutSeconds"` - ElapsedMilliseconds int64 `json:"ElapsedMilliseconds"` - IsPlaying bool `json:"IsPlaying"` - IsPaused bool `json:"IsPaused"` - Message string `json:"Message"` + Id string `json:"Id"` + Status string `json:"Status"` + IsEnabled bool `json:"IsEnabled"` + IsHit bool `json:"IsHit"` + HitCount int `json:"HitCount"` + TimeoutSeconds int `json:"TimeoutSeconds"` + ElapsedSinceEnabledMilliseconds int64 `json:"ElapsedSinceEnabledMilliseconds"` + IsPlaying bool `json:"IsPlaying"` + IsPaused bool `json:"IsPaused"` + Message string `json:"Message"` } type debugBreakWaitState string @@ -435,19 +435,19 @@ func debugBreakStateError( NextActions: []string{ "Run `uloop enable-debug-break --id ` before waiting.", "Confirm the code path calls `UnityCliLoopDebug.Break(\"\")` with the same id.", - "Check `details.status`, `details.isPlaying`, `details.isPaused`, and `details.remainingMilliseconds` to distinguish a missed code path from an already-paused Editor.", + "Check `details.status`, `details.isPlaying`, `details.isPaused`, `details.elapsedSinceEnabledMilliseconds`, and `details.remainingMilliseconds` to distinguish a missed code path from an already-paused Editor.", "If the marker is inside a custom asmdef, add a reference to `UnityCLILoop.PausePoints.Runtime`.", }, Details: map[string]any{ - "id": options.id, - "status": response.Status, - "hitCount": response.HitCount, - "timeoutSeconds": options.timeoutSeconds, - "elapsedMilliseconds": response.ElapsedMilliseconds, - "isPlaying": response.IsPlaying, - "isPaused": response.IsPaused, - "remainingMilliseconds": debugBreakRemainingMilliseconds(options, response), - "markerMessage": response.Message, + "id": options.id, + "status": response.Status, + "hitCount": response.HitCount, + "timeoutSeconds": options.timeoutSeconds, + "elapsedSinceEnabledMilliseconds": response.ElapsedSinceEnabledMilliseconds, + "isPlaying": response.IsPlaying, + "isPaused": response.IsPaused, + "remainingMilliseconds": debugBreakRemainingMilliseconds(options, response), + "markerMessage": response.Message, }, } } @@ -459,7 +459,7 @@ func debugBreakRemainingMilliseconds(options waitForDebugBreakOptions, response } totalMilliseconds := int64(timeoutSeconds) * int64(time.Second/time.Millisecond) - remainingMilliseconds := totalMilliseconds - response.ElapsedMilliseconds + remainingMilliseconds := totalMilliseconds - response.ElapsedSinceEnabledMilliseconds if remainingMilliseconds <= 0 { return 0 } diff --git a/cli/internal/cli/debug_break_wait_test.go b/cli/internal/cli/debug_break_wait_test.go index 28ef42533d..1feff9867b 100644 --- a/cli/internal/cli/debug_break_wait_test.go +++ b/cli/internal/cli/debug_break_wait_test.go @@ -76,14 +76,14 @@ func TestRunWaitForDebugBreakClearsEnabledMarkerAfterTimeout(t *testing.T) { id string, ) (debugBreakStatusResponse, error) { return debugBreakStatusResponse{ - Id: id, - Status: debugBreakStatusEnabled, - IsEnabled: true, - TimeoutSeconds: 1, - ElapsedMilliseconds: 100, - IsPlaying: true, - IsPaused: false, - Message: "Debug break enabled.", + Id: id, + Status: debugBreakStatusEnabled, + IsEnabled: true, + TimeoutSeconds: 1, + ElapsedSinceEnabledMilliseconds: 100, + IsPlaying: true, + IsPaused: false, + Message: "Debug break enabled.", }, nil } @@ -124,6 +124,9 @@ func TestRunWaitForDebugBreakClearsEnabledMarkerAfterTimeout(t *testing.T) { if envelope.Error.Details["markerMessage"] != "Debug break enabled." { t.Fatalf("markerMessage detail mismatch: %#v", envelope.Error.Details) } + if envelope.Error.Details["elapsedSinceEnabledMilliseconds"] != float64(100) { + t.Fatalf("elapsedSinceEnabledMilliseconds detail mismatch: %#v", envelope.Error.Details) + } if envelope.Error.Details["remainingMilliseconds"] != float64(900) { t.Fatalf("remainingMilliseconds detail mismatch: %#v", envelope.Error.Details) } @@ -207,12 +210,12 @@ func TestRunWaitForDebugBreakReportsNotEnabledError(t *testing.T) { // Verifies expired markers report no remaining enabled lifetime. func TestDebugBreakExpiredErrorReportsNoRemainingTime(t *testing.T) { response := debugBreakStatusResponse{ - Id: "jump", - Status: debugBreakStatusExpired, - TimeoutSeconds: 1, - ElapsedMilliseconds: 1200, - IsPlaying: true, - Message: "Debug break expired before it was hit.", + Id: "jump", + Status: debugBreakStatusExpired, + TimeoutSeconds: 1, + ElapsedSinceEnabledMilliseconds: 1200, + IsPlaying: true, + Message: "Debug break expired before it was hit.", } cliErr := debugBreakWaitError("/tmp/MyProject", waitForDebugBreakOptions{ From 808cb61ee133d0a2ff824a93a6217e6cf27188b0 Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 3 Jun 2026 22:08:54 +0900 Subject: [PATCH 12/26] Release input state on debug break interruption Flush keyboard and mouse cleanup when Unity is paused so debug-break inspection does not leave injected input state active after the tool returns. --- .../Tests/PlayMode/SimulateKeyboardTests.cs | 2 ++ .../Tests/PlayMode/SimulateMouseInputTests.cs | 2 ++ .../SimulateKeyboardUseCase.cs | 9 ++++++- .../SimulateMouseInputUseCase.cs | 25 ++++++++++++++++++- 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/Assets/Tests/PlayMode/SimulateKeyboardTests.cs b/Assets/Tests/PlayMode/SimulateKeyboardTests.cs index a749b0abc3..13b4296990 100644 --- a/Assets/Tests/PlayMode/SimulateKeyboardTests.cs +++ b/Assets/Tests/PlayMode/SimulateKeyboardTests.cs @@ -145,6 +145,7 @@ public IEnumerator Press_WhenUnityPausesDuringObservation_Should_CompleteAsDebug Assert.AreEqual("Space", lastResponse.KeyName); Assert.IsNull(lastResponse.DebugBreakId); Assert.IsNull(lastResponse.DebugBreakHitCount); + Assert.IsFalse(keyboard[Key.Space].isPressed, "Debug-break interruption should release the injected key state."); Assert.IsFalse(SimulateKeyboardOverlayState.IsActive, "Debug-break interruption should clear keyboard overlay state."); } @@ -180,6 +181,7 @@ public IEnumerator Press_WhenDebugBreakMarkerHits_Should_ReturnMarkerDetails() Assert.IsTrue(lastResponse.InterruptedByDebugBreak); Assert.AreEqual("space-press", lastResponse.DebugBreakId); Assert.AreEqual(1, lastResponse.DebugBreakHitCount); + Assert.IsFalse(keyboard[Key.Space].isPressed, "Marker interruption should release the injected key state."); } [UnityTest] diff --git a/Assets/Tests/PlayMode/SimulateMouseInputTests.cs b/Assets/Tests/PlayMode/SimulateMouseInputTests.cs index a412dea463..508d8f2a38 100644 --- a/Assets/Tests/PlayMode/SimulateMouseInputTests.cs +++ b/Assets/Tests/PlayMode/SimulateMouseInputTests.cs @@ -133,6 +133,7 @@ public IEnumerator Click_WhenUnityPausesDuringObservation_Should_CompleteAsDebug Assert.AreEqual("Left", lastResponse.Button); Assert.IsNull(lastResponse.DebugBreakId); Assert.IsNull(lastResponse.DebugBreakHitCount); + Assert.IsFalse(mouse.leftButton.isPressed, "Debug-break interruption should release the injected mouse button state."); Assert.IsFalse(SimulateMouseInputOverlayState.HasAnyActivity, "Debug-break interruption should clear mouse overlay state."); } @@ -167,6 +168,7 @@ public IEnumerator Click_WhenDebugBreakMarkerHits_Should_ReturnMarkerDetails() Assert.IsTrue(lastResponse.InterruptedByDebugBreak); Assert.AreEqual("left-click", lastResponse.DebugBreakId); Assert.AreEqual(1, lastResponse.DebugBreakHitCount); + Assert.IsFalse(mouse.leftButton.isPressed, "Marker interruption should release the injected mouse button state."); } #endregion diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs index e6526d83a8..8ca7ff8ac5 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs @@ -384,12 +384,19 @@ private static async Task ReleaseKeyStateIfPossible(Keyboard keyboard, Key key) return; } + if (EditorApplication.isPaused) + { + KeyboardKeyState.SetKeyState(keyboard, key, false); + InputSystemUpdateHelper.RunExplicitUpdate(InputUpdateTypeResolver.Resolve()); + return; + } + await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate(() => KeyboardKeyState.SetKeyState(keyboard, key, false), CancellationToken.None); } private static bool CanInjectKeyboardState(Keyboard keyboard) { - return EditorApplication.isPlaying && !EditorApplication.isPaused && Keyboard.current == keyboard; + return EditorApplication.isPlaying && keyboard != null; } #endif } diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs index 1bd94dd69e..19710922ef 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs @@ -392,6 +392,7 @@ await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( previousT = t; if (EditorApplication.isPaused) { + ResetDeltaIfPossible(mouse); SimulateMouseInputOverlayState.Clear(); return InterruptedActionResult(UnityCliLoopMouseInputAction.SmoothDelta); } @@ -410,6 +411,7 @@ await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( InputSimulationWaitOutcome waitOutcome = await InputSystemUpdateHelper.WaitForRuntimeFrames(1, ct); if (waitOutcome == InputSimulationWaitOutcome.Paused) { + ResetDeltaIfPossible(mouse); SimulateMouseInputOverlayState.Clear(); return InterruptedActionResult(UnityCliLoopMouseInputAction.SmoothDelta); } @@ -490,13 +492,34 @@ private static async Task ReleaseButtonIfPossible(Mouse mouse, RuntimeMouseButto return; } + if (EditorApplication.isPaused) + { + MouseInputState.SetButtonState(mouse, button, false); + InputSystemUpdateHelper.RunExplicitUpdate(InputUpdateTypeResolver.Resolve()); + return; + } + await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( () => MouseInputState.SetButtonState(mouse, button, false), CancellationToken.None); } + private static void ResetDeltaIfPossible(Mouse mouse) + { + if (!CanInjectMouseState(mouse)) + { + return; + } + + MouseInputState.InjectDelta(mouse, Vector2.zero); + if (EditorApplication.isPaused) + { + InputSystemUpdateHelper.RunExplicitUpdate(InputUpdateTypeResolver.Resolve()); + } + } + private static bool CanInjectMouseState(Mouse mouse) { - return EditorApplication.isPlaying && !EditorApplication.isPaused && Mouse.current == mouse; + return EditorApplication.isPlaying && mouse != null; } private static RuntimeMouseButton ToRuntimeMouseButton(UnityCliLoopMouseButton button) From b54f5641a3d82ee99776037461b951e8219b65c7 Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 3 Jun 2026 22:20:16 +0900 Subject: [PATCH 13/26] Fix SmoothDelta debug-break interruption Wait one runtime frame after each SmoothDelta injection so gameplay Update can hit a debug-break marker before the next input update is scheduled. --- .../Tests/PlayMode/SimulateMouseInputTests.cs | 78 +++++++++++++++++++ .../SimulateMouseInputUseCase.cs | 18 +---- 2 files changed, 80 insertions(+), 16 deletions(-) diff --git a/Assets/Tests/PlayMode/SimulateMouseInputTests.cs b/Assets/Tests/PlayMode/SimulateMouseInputTests.cs index 508d8f2a38..aef127ee48 100644 --- a/Assets/Tests/PlayMode/SimulateMouseInputTests.cs +++ b/Assets/Tests/PlayMode/SimulateMouseInputTests.cs @@ -258,6 +258,51 @@ public IEnumerator LongPress_Should_RestoreRunInBackground_WhenOriginallyDisable #endregion + #region SmoothDelta Tests + + [UnityTest] + public IEnumerator SmoothDelta_WhenDebugBreakMarkerHitsDuringGameplayUpdate_Should_ReturnMarkerDetails() + { + // Verifies SmoothDelta observes gameplay-frame debug breaks before scheduling the next delta. + yield return null; + + MouseDeltaDebugBreakObserver observer = mouseObserverGo.AddComponent(); + observer.MarkerId = "smooth-delta"; + UloopPausePointRegistry.ConfigureForTests( + new FakePausePointPauseController(), + () => new DateTime(2026, 6, 3, 0, 0, 0, DateTimeKind.Utc)); + UloopPausePointRegistry.Enable(observer.MarkerId, 30); + + Task task = tool.ExecuteAsync(new JObject + { + ["action"] = MouseInputAction.SmoothDelta.ToString(), + ["deltaX"] = 120f, + ["deltaY"] = 0f, + ["duration"] = 1f + }, System.Threading.CancellationToken.None); + + yield return new WaitUntil(() => task.IsCompleted || observer.HasTriggered); + Assert.IsTrue(observer.HasTriggered, "The test must hit the marker from gameplay Update."); + + try + { + yield return WaitForTask(task); + } + finally + { + InputSystemUpdateHelper.ResetPauseProviderForTests(); + } + + lastResponse = (SimulateMouseInputResponse)task.Result; + Assert.IsTrue(lastResponse.Success); + Assert.IsTrue(lastResponse.InterruptedByDebugBreak); + Assert.AreEqual("SmoothDelta", lastResponse.Action); + Assert.AreEqual("smooth-delta", lastResponse.DebugBreakId); + Assert.AreEqual(1, lastResponse.DebugBreakHitCount); + } + + #endregion + #region MoveDelta Tests [UnityTest] @@ -372,5 +417,38 @@ public void ResetCount() LeftButtonPressedUpdateCount = 0; } } + + /// + /// Test support type that hits a debug-break marker when gameplay observes mouse delta. + /// + public class MouseDeltaDebugBreakObserver : MonoBehaviour + { + public string MarkerId { get; set; } = ""; + public bool HasTriggered { get; private set; } + + private void Update() + { + if (HasTriggered) + { + return; + } + + Mouse mouse = Mouse.current; + if (mouse == null) + { + return; + } + + Vector2 delta = mouse.delta.ReadValue(); + if (delta.sqrMagnitude <= 0f) + { + return; + } + + HasTriggered = true; + UnityCliLoopDebug.Break(MarkerId); + InputSystemUpdateHelper.ConfigurePauseProviderForTests(() => true); + } + } } #endif diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs index 19710922ef..f1e18a308e 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs @@ -390,7 +390,8 @@ await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( () => MouseInputState.InjectDelta(mouse, frameDelta), ct); previousT = t; - if (EditorApplication.isPaused) + InputSimulationWaitOutcome waitOutcome = await InputSystemUpdateHelper.WaitForRuntimeFrames(1, ct); + if (waitOutcome == InputSimulationWaitOutcome.Paused) { ResetDeltaIfPossible(mouse); SimulateMouseInputOverlayState.Clear(); @@ -401,21 +402,6 @@ await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( { break; } - - // Explicit/manual update completes synchronously, so an extra - // frame delay is needed to prevent the loop from collapsing into - // a single burst. Dynamic/Fixed modes already yield naturally via - // ApplyOnNextConfiguredUpdate's onBeforeUpdate callback. - if (InputUpdateTypeResolver.RequiresExplicitUpdate()) - { - InputSimulationWaitOutcome waitOutcome = await InputSystemUpdateHelper.WaitForRuntimeFrames(1, ct); - if (waitOutcome == InputSimulationWaitOutcome.Paused) - { - ResetDeltaIfPossible(mouse); - SimulateMouseInputOverlayState.Clear(); - return InterruptedActionResult(UnityCliLoopMouseInputAction.SmoothDelta); - } - } } // Reset delta to zero after the smooth operation completes From a258049ae3f099628f2a5ec85b900bf47e235c5d Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 3 Jun 2026 22:20:26 +0900 Subject: [PATCH 14/26] Bump CLI contract for debug-break commands Require the CLI release that includes the new native debug-break commands so package users get an update prompt instead of unknown-command failures. --- Assets/Tests/Editor/CliSetupApplicationServiceTests.cs | 8 ++++---- Packages/src/Editor/Domain/CliConstants.cs | 2 +- cli/contract.json | 2 +- cli/internal/tools/default-tools.json | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Assets/Tests/Editor/CliSetupApplicationServiceTests.cs b/Assets/Tests/Editor/CliSetupApplicationServiceTests.cs index 0db1594228..4deeec4afd 100644 --- a/Assets/Tests/Editor/CliSetupApplicationServiceTests.cs +++ b/Assets/Tests/Editor/CliSetupApplicationServiceTests.cs @@ -31,14 +31,14 @@ public async Task InstallGlobalCliAsync_UsesMinimumRequiredCliReleaseTag() } [Test] - public void GetMinimumRequiredCliVersion_RequiresExternalSceneChangeFlagCliRelease() + public void GetMinimumRequiredCliVersion_RequiresDebugBreakCliRelease() { - // Verifies this package release requires the CLI with external Scene change options. + // Verifies this package release requires the CLI with debug-break native commands. CliSetupApplicationService service = new( new FakeCliInstallationDetector(new string[] { null }), new FakeNativeCliInstaller()); - Assert.That(service.GetMinimumRequiredCliVersion(), Is.EqualTo("3.0.0-beta.24")); + Assert.That(service.GetMinimumRequiredCliVersion(), Is.EqualTo("3.0.0-beta.25")); } [Test] @@ -49,7 +49,7 @@ public void GetMinimumRequiredCliReleaseTag_UsesCliGitHubReleaseTag() new FakeCliInstallationDetector(new string[] { null }), new FakeNativeCliInstaller()); - Assert.That(service.GetMinimumRequiredCliReleaseTag(), Is.EqualTo("cli-v3.0.0-beta.24")); + Assert.That(service.GetMinimumRequiredCliReleaseTag(), Is.EqualTo("cli-v3.0.0-beta.25")); } [Test] diff --git a/Packages/src/Editor/Domain/CliConstants.cs b/Packages/src/Editor/Domain/CliConstants.cs index aa953aac8b..1e063fecbd 100644 --- a/Packages/src/Editor/Domain/CliConstants.cs +++ b/Packages/src/Editor/Domain/CliConstants.cs @@ -6,7 +6,7 @@ namespace io.github.hatayama.UnityCliLoop.Domain public static class CliConstants { public const string EXECUTABLE_NAME = "uloop"; - public const string MINIMUM_REQUIRED_CLI_VERSION = "3.0.0-beta.24"; + public const string MINIMUM_REQUIRED_CLI_VERSION = "3.0.0-beta.25"; public const string MINIMUM_REQUIRED_CLI_RELEASE_TAG = CLI_RELEASE_TAG_PREFIX + MINIMUM_REQUIRED_CLI_VERSION; public const string VERSION_FLAG = "--version"; public const string SHORT_VERSION_FLAG = "-v"; diff --git a/cli/contract.json b/cli/contract.json index 6279babbe5..06e57c6b20 100644 --- a/cli/contract.json +++ b/cli/contract.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "cliVersion": "3.0.0-beta.24" + "cliVersion": "3.0.0-beta.25" } diff --git a/cli/internal/tools/default-tools.json b/cli/internal/tools/default-tools.json index 772806ed56..735865b7c5 100644 --- a/cli/internal/tools/default-tools.json +++ b/cli/internal/tools/default-tools.json @@ -1,5 +1,5 @@ { - "version": "3.0.0-beta.24", + "version": "3.0.0-beta.25", "tools": [ { "name": "compile", From 862e540cc57312b947effd1e5d05c9c5be645cad Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 4 Jun 2026 00:49:47 +0900 Subject: [PATCH 15/26] Improve Unity command diagnostics Clarify short input durations, add compile hints for test asmdef setup, and avoid reading Unity editor state from background-thread busy responses so parallel commands return server_busy instead of internal Unity thread errors. --- .../CompileSessionResultServiceTests.cs | 27 +++++++++ .../InputSimulationDurationFormatterTests.cs | 30 ++++++++++ ...utSimulationDurationFormatterTests.cs.meta | 11 ++++ .../UnityCliLoopToolExecutionServiceTests.cs | 57 +++++++++++++++++++ ...tyCliLoopToolExecutionServiceTests.cs.meta | 11 ++++ .../UnityCliLoopToolExecutionService.cs | 25 ++++++-- .../InputSimulationDurationFormatter.cs | 18 ++++++ .../InputSimulationDurationFormatter.cs.meta | 11 ++++ .../Compile/CompileSessionResultService.cs | 49 +++++++++++++++- .../SimulateKeyboardUseCase.cs | 2 +- .../SimulateMouseInputUseCase.cs | 4 +- 11 files changed, 236 insertions(+), 9 deletions(-) create mode 100644 Assets/Tests/Editor/InputSimulationDurationFormatterTests.cs create mode 100644 Assets/Tests/Editor/InputSimulationDurationFormatterTests.cs.meta create mode 100644 Assets/Tests/Editor/UnityCliLoopToolExecutionServiceTests.cs create mode 100644 Assets/Tests/Editor/UnityCliLoopToolExecutionServiceTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/Common/InputSimulation/InputSimulationDurationFormatter.cs create mode 100644 Packages/src/Editor/FirstPartyTools/Common/InputSimulation/InputSimulationDurationFormatter.cs.meta diff --git a/Assets/Tests/Editor/CompileSessionResultServiceTests.cs b/Assets/Tests/Editor/CompileSessionResultServiceTests.cs index 000533691d..05fef90734 100644 --- a/Assets/Tests/Editor/CompileSessionResultServiceTests.cs +++ b/Assets/Tests/Editor/CompileSessionResultServiceTests.cs @@ -138,5 +138,32 @@ public void CreateCompileResult_WhenForceCompileHasPreservedFailure_MapsDetailed Assert.That(response.Errors[0].File, Is.EqualTo("Assets/Scenes/SampleScene.unity")); Assert.That(response.Message, Does.Contain("externally")); } + + [Test] + public void CreateCompileResult_WhenUnityTestFrameworkSymbolIsMissing_AddsTestAsmdefHint() + { + // Verifies compile failures from unmarked test asmdefs include the TestAssemblies fix. + CompilerMessage error = new CompilerMessage + { + type = CompilerMessageType.Error, + message = "The type or namespace name 'UnityTestAttribute' could not be found", + file = "Assets/Tests/PlayMode/SampleTest.cs", + line = 8 + }; + CompileResult result = new CompileResult( + success: false, + errorCount: 1, + warningCount: 0, + completedAt: DateTime.Now, + messages: new[] { error }, + errors: new[] { error }, + warnings: Array.Empty()); + + UnityCliLoopCompileResult response = + CompileSessionResultService.CreateCompileResult(result, forceRecompile: false); + + Assert.That(response.Message, Does.Contain("TestAssemblies")); + Assert.That(response.Message, Does.Contain("com.unity.test-framework")); + } } } diff --git a/Assets/Tests/Editor/InputSimulationDurationFormatterTests.cs b/Assets/Tests/Editor/InputSimulationDurationFormatterTests.cs new file mode 100644 index 0000000000..b058c9de0c --- /dev/null +++ b/Assets/Tests/Editor/InputSimulationDurationFormatterTests.cs @@ -0,0 +1,30 @@ +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Tests user-facing input-simulation duration formatting. + /// + public sealed class InputSimulationDurationFormatterTests + { + [Test] + public void FormatSeconds_WhenDurationHasSubTenthPrecision_PreservesMeaningfulDigits() + { + // Verifies short requested durations are not rounded up to a misleading tenth of a second. + string formatted = InputSimulationDurationFormatter.FormatSeconds(0.05f); + + Assert.That(formatted, Is.EqualTo("0.05")); + } + + [Test] + public void FormatSeconds_WhenDurationUsesWholeSeconds_DropsTrailingDecimal() + { + // Verifies whole-second durations stay compact in command result messages. + string formatted = InputSimulationDurationFormatter.FormatSeconds(2f); + + Assert.That(formatted, Is.EqualTo("2")); + } + } +} diff --git a/Assets/Tests/Editor/InputSimulationDurationFormatterTests.cs.meta b/Assets/Tests/Editor/InputSimulationDurationFormatterTests.cs.meta new file mode 100644 index 0000000000..66661e41cb --- /dev/null +++ b/Assets/Tests/Editor/InputSimulationDurationFormatterTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 99af7b1d355d4f0c85c280aeb19bdde5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/UnityCliLoopToolExecutionServiceTests.cs b/Assets/Tests/Editor/UnityCliLoopToolExecutionServiceTests.cs new file mode 100644 index 0000000000..247ab36477 --- /dev/null +++ b/Assets/Tests/Editor/UnityCliLoopToolExecutionServiceTests.cs @@ -0,0 +1,57 @@ +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.Application; +using io.github.hatayama.UnityCliLoop.Infrastructure; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Tests tool execution coordination helpers. + /// + public sealed class UnityCliLoopToolExecutionServiceTests + { + [Test] + public void CreateBusyException_WhenDispatcherReportsBackgroundThread_DoesNotCaptureEditorState() + { + // Verifies busy responses can be created before the request reaches Unity's main thread. + CapturingMainThreadDispatcher dispatcher = new CapturingMainThreadDispatcher(); + MainThreadSwitcher.RegisterService(dispatcher); + + try + { + UnityCliLoopToolBusyException exception = + UnityCliLoopToolExecutionService.CreateBusyException("running-tool", "requested-tool"); + + Assert.That(exception.RunningToolName, Is.EqualTo("running-tool")); + Assert.That(exception.RequestedToolName, Is.EqualTo("requested-tool")); + Assert.That(exception.IsPlaying, Is.False); + Assert.That(exception.IsPaused, Is.False); + } + finally + { + RestoreEditorMainThreadDispatcher(); + } + } + + private static void RestoreEditorMainThreadDispatcher() + { + EditorMainThreadDispatcher dispatcher = new EditorMainThreadDispatcher(); + MainThreadSwitcher.RegisterService(dispatcher); + dispatcher.Initialize(); + } + + private sealed class CapturingMainThreadDispatcher : IMainThreadDispatcher + { + public bool IsMainThread => false; + + public void Initialize() + { + } + + public void AddContinuation(System.Action continuation) + { + Assert.That(continuation, Is.Not.Null); + } + } + } +} diff --git a/Assets/Tests/Editor/UnityCliLoopToolExecutionServiceTests.cs.meta b/Assets/Tests/Editor/UnityCliLoopToolExecutionServiceTests.cs.meta new file mode 100644 index 0000000000..5b3f396f83 --- /dev/null +++ b/Assets/Tests/Editor/UnityCliLoopToolExecutionServiceTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9e12df2738074ead93ebfdbd84dee5b8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Application/UnityCliLoopToolExecutionService.cs b/Packages/src/Editor/Application/UnityCliLoopToolExecutionService.cs index 80ca22dba0..3df3bfe2f5 100644 --- a/Packages/src/Editor/Application/UnityCliLoopToolExecutionService.cs +++ b/Packages/src/Editor/Application/UnityCliLoopToolExecutionService.cs @@ -50,11 +50,7 @@ internal async Task ExecuteToolAsync( if (!TryEnterExecution(toolName, out string runningToolName)) { - throw new UnityCliLoopToolBusyException( - runningToolName, - toolName, - EditorApplication.isPlaying, - EditorApplication.isPaused); + throw CreateBusyException(runningToolName, toolName); } try @@ -122,6 +118,25 @@ private static bool CanShareExecutionSlot(string runningToolName, string request && requestedToolName == UnityCliLoopConstants.TOOL_NAME_EXECUTE_DYNAMIC_CODE; } + internal static UnityCliLoopToolBusyException CreateBusyException( + string runningToolName, + string requestedToolName) + { + Debug.Assert(!string.IsNullOrWhiteSpace(runningToolName), "runningToolName must not be null or whitespace"); + Debug.Assert(!string.IsNullOrWhiteSpace(requestedToolName), "requestedToolName must not be null or whitespace"); + + if (!MainThreadSwitcher.IsMainThread) + { + return new UnityCliLoopToolBusyException(runningToolName, requestedToolName); + } + + return new UnityCliLoopToolBusyException( + runningToolName, + requestedToolName, + EditorApplication.isPlaying, + EditorApplication.isPaused); + } + private string GetRunningToolNameInsideLock() { return string.IsNullOrWhiteSpace(_runningToolName) diff --git a/Packages/src/Editor/FirstPartyTools/Common/InputSimulation/InputSimulationDurationFormatter.cs b/Packages/src/Editor/FirstPartyTools/Common/InputSimulation/InputSimulationDurationFormatter.cs new file mode 100644 index 0000000000..448e704edc --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/InputSimulation/InputSimulationDurationFormatter.cs @@ -0,0 +1,18 @@ +#nullable enable +using System.Globalization; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Formats input-simulation durations for user-facing CLI result messages. + /// + internal static class InputSimulationDurationFormatter + { + private const string SecondsFormat = "0.###"; + + internal static string FormatSeconds(float seconds) + { + return seconds.ToString(SecondsFormat, CultureInfo.InvariantCulture); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Common/InputSimulation/InputSimulationDurationFormatter.cs.meta b/Packages/src/Editor/FirstPartyTools/Common/InputSimulation/InputSimulationDurationFormatter.cs.meta new file mode 100644 index 0000000000..fb4fe83b66 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/InputSimulation/InputSimulationDurationFormatter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5223c9557411434a895cad0ebc1434ae +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileSessionResultService.cs b/Packages/src/Editor/FirstPartyTools/Compile/CompileSessionResultService.cs index 71bc73a85b..607667f429 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/CompileSessionResultService.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileSessionResultService.cs @@ -14,6 +14,9 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// internal static class CompileSessionResultService { + private const string MissingTestFrameworkReferenceHint = + "Possible test asmdef issue: Unity test framework symbols are missing. Make sure com.unity.test-framework is installed and add optionalUnityReferences: [\"TestAssemblies\"] or enable testAssemblies on the test asmdef."; + internal static UnityCliLoopCompileResult CreateCompileResult( CompileResult result, bool forceRecompile) @@ -45,7 +48,7 @@ internal static UnityCliLoopCompileResult CreateCompileResult( WarningCount = result.Warnings?.Length ?? 0, Errors = ToIssues(result.Errors), Warnings = ToIssues(result.Warnings), - Message = result.Message + Message = AddMissingTestFrameworkReferenceHint(result.Message, result.Errors) }; } @@ -119,6 +122,50 @@ private static UnityCliLoopCompileIssue[] ToIssues(UnityEditor.Compilation.Compi Line = message.line }).ToArray(); } + + private static string AddMissingTestFrameworkReferenceHint( + string message, + UnityEditor.Compilation.CompilerMessage[] errors) + { + if (!ContainsMissingTestFrameworkReference(errors)) + { + return message; + } + + if (string.IsNullOrWhiteSpace(message)) + { + return MissingTestFrameworkReferenceHint; + } + + if (message.Contains(MissingTestFrameworkReferenceHint, StringComparison.Ordinal)) + { + return message; + } + + return $"{message} {MissingTestFrameworkReferenceHint}"; + } + + private static bool ContainsMissingTestFrameworkReference( + UnityEditor.Compilation.CompilerMessage[] errors) + { + if (errors == null) + { + return false; + } + + foreach (UnityEditor.Compilation.CompilerMessage error in errors) + { + string errorMessage = error.message ?? string.Empty; + if (errorMessage.Contains("UnityTestAttribute", StringComparison.Ordinal) + || errorMessage.Contains("UnityEngine.TestTools", StringComparison.Ordinal) + || errorMessage.Contains("NUnit.Framework", StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } } /// diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs index 8ca7ff8ac5..701676be40 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs @@ -206,7 +206,7 @@ private async Task ExecutePress( return InterruptedPressResult(keyName); } - string durationText = duration > 0f ? $" for {duration:F1}s" : ""; + string durationText = duration > 0f ? $" for {InputSimulationDurationFormatter.FormatSeconds(duration)}s" : ""; return new UnityCliLoopKeyboardSimulationResult { Success = true, diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs index f1e18a308e..5ae729df61 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs @@ -222,7 +222,7 @@ await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( inputPos); } - string durationText = request.Duration > 0f ? $" for {request.Duration:F1}s" : ""; + string durationText = request.Duration > 0f ? $" for {InputSimulationDurationFormatter.FormatSeconds(request.Duration)}s" : ""; return new UnityCliLoopMouseInputSimulationResult { Success = true, @@ -300,7 +300,7 @@ await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( return new UnityCliLoopMouseInputSimulationResult { Success = true, - Message = $"Long-pressed {buttonName} at ({inputPos.x:F1}, {inputPos.y:F1}) for {request.Duration:F1}s", + Message = $"Long-pressed {buttonName} at ({inputPos.x:F1}, {inputPos.y:F1}) for {InputSimulationDurationFormatter.FormatSeconds(request.Duration)}s", Action = UnityCliLoopMouseInputAction.LongPress.ToString(), Button = buttonName, PositionX = inputPos.x, From 949da178eeb320281ce2752768c7fe54daabfaf4 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 4 Jun 2026 01:05:00 +0900 Subject: [PATCH 16/26] Trim Unity meta trailing whitespace Keep the v3-beta branch diff clean for git diff --check after generated Unity meta files were added. --- .../Editor/InputSimulationDurationFormatterTests.cs.meta | 6 +++--- Assets/Tests/Editor/PausePointTests.cs.meta | 6 +++--- .../Editor/UnityCliLoopToolExecutionServiceTests.cs.meta | 6 +++--- Packages/src/Editor/CliOnlyTools~/PausePoint.meta | 6 +++--- Packages/src/Editor/CliOnlyTools~/PausePoint/Skill.meta | 6 +++--- .../src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md.meta | 6 +++--- .../InputSimulationDurationFormatter.cs.meta | 6 +++--- Packages/src/Editor/FirstPartyTools/PausePoint.meta | 6 +++--- .../FirstPartyTools/PausePoint/PausePointTools.cs.meta | 6 +++--- ...ityCLILoop.FirstPartyTools.PausePoint.Editor.asmdef.meta | 6 +++--- .../Api/PausePointStatusBridgeCommand.cs.meta | 6 +++--- Packages/src/Runtime/PausePoints.meta | 6 +++--- Packages/src/Runtime/PausePoints/AssemblyInfo.cs.meta | 6 +++--- .../src/Runtime/PausePoints/UloopPausePointRegistry.cs.meta | 6 +++--- .../UnityCLILoop.PausePoints.Runtime.asmdef.meta | 6 +++--- Packages/src/Runtime/PausePoints/UnityCliLoopDebug.cs.meta | 6 +++--- 16 files changed, 48 insertions(+), 48 deletions(-) diff --git a/Assets/Tests/Editor/InputSimulationDurationFormatterTests.cs.meta b/Assets/Tests/Editor/InputSimulationDurationFormatterTests.cs.meta index 66661e41cb..bee5f5199c 100644 --- a/Assets/Tests/Editor/InputSimulationDurationFormatterTests.cs.meta +++ b/Assets/Tests/Editor/InputSimulationDurationFormatterTests.cs.meta @@ -6,6 +6,6 @@ MonoImporter: defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/PausePointTests.cs.meta b/Assets/Tests/Editor/PausePointTests.cs.meta index b67375f015..edb7364f0c 100644 --- a/Assets/Tests/Editor/PausePointTests.cs.meta +++ b/Assets/Tests/Editor/PausePointTests.cs.meta @@ -6,6 +6,6 @@ MonoImporter: defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/UnityCliLoopToolExecutionServiceTests.cs.meta b/Assets/Tests/Editor/UnityCliLoopToolExecutionServiceTests.cs.meta index 5b3f396f83..26b69b7d76 100644 --- a/Assets/Tests/Editor/UnityCliLoopToolExecutionServiceTests.cs.meta +++ b/Assets/Tests/Editor/UnityCliLoopToolExecutionServiceTests.cs.meta @@ -6,6 +6,6 @@ MonoImporter: defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint.meta b/Packages/src/Editor/CliOnlyTools~/PausePoint.meta index 535a784254..4c775e7656 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint.meta +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint.meta @@ -3,6 +3,6 @@ guid: f0336ed66c5e45a39afa8c0e11f94dd0 folderAsset: yes DefaultImporter: externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill.meta b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill.meta index 8953a5ced7..898172cc7e 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill.meta +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill.meta @@ -3,6 +3,6 @@ guid: 842781e920d14996b5f2cafff69f80db folderAsset: yes DefaultImporter: externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md.meta b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md.meta index 2a97ed5bf3..a1f8969697 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md.meta +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md.meta @@ -2,6 +2,6 @@ fileFormatVersion: 2 guid: 6cb0f0310dd648ac86977590bcfa405f TextScriptImporter: externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Common/InputSimulation/InputSimulationDurationFormatter.cs.meta b/Packages/src/Editor/FirstPartyTools/Common/InputSimulation/InputSimulationDurationFormatter.cs.meta index fb4fe83b66..3e3a472a0a 100644 --- a/Packages/src/Editor/FirstPartyTools/Common/InputSimulation/InputSimulationDurationFormatter.cs.meta +++ b/Packages/src/Editor/FirstPartyTools/Common/InputSimulation/InputSimulationDurationFormatter.cs.meta @@ -6,6 +6,6 @@ MonoImporter: defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint.meta b/Packages/src/Editor/FirstPartyTools/PausePoint.meta index 466d1ee8c2..919d00a53e 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint.meta +++ b/Packages/src/Editor/FirstPartyTools/PausePoint.meta @@ -3,6 +3,6 @@ guid: 15bef1671be348ba88df00a8f77f17e6 folderAsset: yes DefaultImporter: externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs.meta b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs.meta index 36822b3edc..319c845923 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs.meta +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs.meta @@ -6,6 +6,6 @@ MonoImporter: defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/UnityCLILoop.FirstPartyTools.PausePoint.Editor.asmdef.meta b/Packages/src/Editor/FirstPartyTools/PausePoint/UnityCLILoop.FirstPartyTools.PausePoint.Editor.asmdef.meta index 4078b2fa67..f6e7edc674 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/UnityCLILoop.FirstPartyTools.PausePoint.Editor.asmdef.meta +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/UnityCLILoop.FirstPartyTools.PausePoint.Editor.asmdef.meta @@ -2,6 +2,6 @@ fileFormatVersion: 2 guid: 94d8abc693f543a691a4645a5ff42e5c AssemblyDefinitionImporter: externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs.meta b/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs.meta index 44bd41b9f0..1e38ac0d92 100644 --- a/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs.meta +++ b/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs.meta @@ -6,6 +6,6 @@ MonoImporter: defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Runtime/PausePoints.meta b/Packages/src/Runtime/PausePoints.meta index 8884cb5f33..8f6cda50cc 100644 --- a/Packages/src/Runtime/PausePoints.meta +++ b/Packages/src/Runtime/PausePoints.meta @@ -3,6 +3,6 @@ guid: ffc175d8085b462e953bf596f441c344 folderAsset: yes DefaultImporter: externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Runtime/PausePoints/AssemblyInfo.cs.meta b/Packages/src/Runtime/PausePoints/AssemblyInfo.cs.meta index de25c4e775..73cdf1d046 100644 --- a/Packages/src/Runtime/PausePoints/AssemblyInfo.cs.meta +++ b/Packages/src/Runtime/PausePoints/AssemblyInfo.cs.meta @@ -6,6 +6,6 @@ MonoImporter: defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs.meta b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs.meta index 83cb402ca2..b57442209b 100644 --- a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs.meta +++ b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs.meta @@ -6,6 +6,6 @@ MonoImporter: defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Runtime/PausePoints/UnityCLILoop.PausePoints.Runtime.asmdef.meta b/Packages/src/Runtime/PausePoints/UnityCLILoop.PausePoints.Runtime.asmdef.meta index ae343d4923..73bfd49818 100644 --- a/Packages/src/Runtime/PausePoints/UnityCLILoop.PausePoints.Runtime.asmdef.meta +++ b/Packages/src/Runtime/PausePoints/UnityCLILoop.PausePoints.Runtime.asmdef.meta @@ -2,6 +2,6 @@ fileFormatVersion: 2 guid: 527f26a36b5043c2bd4d4036d04cd76d AssemblyDefinitionImporter: externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Runtime/PausePoints/UnityCliLoopDebug.cs.meta b/Packages/src/Runtime/PausePoints/UnityCliLoopDebug.cs.meta index 436da15cd4..3e13a26690 100644 --- a/Packages/src/Runtime/PausePoints/UnityCliLoopDebug.cs.meta +++ b/Packages/src/Runtime/PausePoints/UnityCliLoopDebug.cs.meta @@ -6,6 +6,6 @@ MonoImporter: defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: From 3054c8655167f5807b15bd923954aac06420acf5 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 4 Jun 2026 01:18:53 +0900 Subject: [PATCH 17/26] Fix debug-break wait timeout edge cases Preserve unknown play state in server-busy responses and add a final status probe before clearing wait-for-debug-break timeouts. --- .../JsonRpcProcessorCliVersionGateTests.cs | 8 +-- .../UnityCliLoopToolExecutionServiceTests.cs | 4 +- .../UnityCliLoopToolBusyException.cs | 8 +-- .../Infrastructure/Api/JsonRpcProcessor.cs | 10 +-- cli/internal/cli/debug_break_wait.go | 53 +++++++++++---- cli/internal/cli/debug_break_wait_test.go | 68 +++++++++++++++++++ 6 files changed, 124 insertions(+), 27 deletions(-) diff --git a/Assets/Tests/Editor/JsonRpcProcessorCliVersionGateTests.cs b/Assets/Tests/Editor/JsonRpcProcessorCliVersionGateTests.cs index d062cacbeb..4587b1dacf 100644 --- a/Assets/Tests/Editor/JsonRpcProcessorCliVersionGateTests.cs +++ b/Assets/Tests/Editor/JsonRpcProcessorCliVersionGateTests.cs @@ -121,8 +121,8 @@ public async Task ProcessRequest_WhenFirstToolWaitsForMainThread_ReturnsServerBu Assert.That(data["type"]?.ToString(), Is.EqualTo("server_busy")); Assert.That(data["runningToolName"]?.ToString(), Is.EqualTo(SingleFlightTestTool.Name)); Assert.That(data["requestedToolName"]?.ToString(), Is.EqualTo(SingleFlightTestTool.Name)); - Assert.That(data["isPlaying"]?.ToObject(), Is.EqualTo(UnityEditor.EditorApplication.isPlaying)); - Assert.That(data["isPaused"]?.ToObject(), Is.EqualTo(UnityEditor.EditorApplication.isPaused)); + Assert.That(data["isPlaying"], Is.Null); + Assert.That(data["isPaused"], Is.Null); LogAssert.NoUnexpectedReceived(); } finally @@ -186,8 +186,8 @@ public async Task ProcessRequest_WhenExecuteDynamicCodeWaitsForMainThread_Allows Assert.That(data["type"]?.ToString(), Is.EqualTo("server_busy")); Assert.That(data["runningToolName"]?.ToString(), Is.EqualTo(UnityCliLoopConstants.TOOL_NAME_EXECUTE_DYNAMIC_CODE)); Assert.That(data["requestedToolName"]?.ToString(), Is.EqualTo(SingleFlightTestTool.Name)); - Assert.That(data["isPlaying"]?.ToObject(), Is.EqualTo(UnityEditor.EditorApplication.isPlaying)); - Assert.That(data["isPaused"]?.ToObject(), Is.EqualTo(UnityEditor.EditorApplication.isPaused)); + Assert.That(data["isPlaying"], Is.Null); + Assert.That(data["isPaused"], Is.Null); LogAssert.NoUnexpectedReceived(); } finally diff --git a/Assets/Tests/Editor/UnityCliLoopToolExecutionServiceTests.cs b/Assets/Tests/Editor/UnityCliLoopToolExecutionServiceTests.cs index 247ab36477..9203b3b3f6 100644 --- a/Assets/Tests/Editor/UnityCliLoopToolExecutionServiceTests.cs +++ b/Assets/Tests/Editor/UnityCliLoopToolExecutionServiceTests.cs @@ -24,8 +24,8 @@ public void CreateBusyException_WhenDispatcherReportsBackgroundThread_DoesNotCap Assert.That(exception.RunningToolName, Is.EqualTo("running-tool")); Assert.That(exception.RequestedToolName, Is.EqualTo("requested-tool")); - Assert.That(exception.IsPlaying, Is.False); - Assert.That(exception.IsPaused, Is.False); + Assert.That(exception.IsPlaying, Is.Null); + Assert.That(exception.IsPaused, Is.Null); } finally { diff --git a/Packages/src/Editor/Application/UnityCliLoopToolBusyException.cs b/Packages/src/Editor/Application/UnityCliLoopToolBusyException.cs index 9312ea81ed..0a57a67213 100644 --- a/Packages/src/Editor/Application/UnityCliLoopToolBusyException.cs +++ b/Packages/src/Editor/Application/UnityCliLoopToolBusyException.cs @@ -10,8 +10,8 @@ public sealed class UnityCliLoopToolBusyException : Exception public UnityCliLoopToolBusyException( string runningToolName, string requestedToolName, - bool isPlaying = false, - bool isPaused = false) + bool? isPlaying = null, + bool? isPaused = null) : base(CreateMessage(runningToolName, requestedToolName)) { RunningToolName = runningToolName; @@ -24,9 +24,9 @@ public UnityCliLoopToolBusyException( public string RequestedToolName { get; } - public bool IsPlaying { get; } + public bool? IsPlaying { get; } - public bool IsPaused { get; } + public bool? IsPaused { get; } private static string CreateMessage(string runningToolName, string requestedToolName) { diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcProcessor.cs b/Packages/src/Editor/Infrastructure/Api/JsonRpcProcessor.cs index 972ba6a7f8..4a395b0d1d 100644 --- a/Packages/src/Editor/Infrastructure/Api/JsonRpcProcessor.cs +++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcProcessor.cs @@ -503,15 +503,17 @@ public class ServerBusyErrorData : JsonRpcErrorData public string requestedToolName { get; } - public bool isPlaying { get; } + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public bool? isPlaying { get; } - public bool isPaused { get; } + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public bool? isPaused { get; } public ServerBusyErrorData( string runningToolName, string requestedToolName, - bool isPlaying, - bool isPaused, + bool? isPlaying, + bool? isPaused, string message) : base(message) { diff --git a/cli/internal/cli/debug_break_wait.go b/cli/internal/cli/debug_break_wait.go index 6c636c33ed..ba68eaf448 100644 --- a/cli/internal/cli/debug_break_wait.go +++ b/cli/internal/cli/debug_break_wait.go @@ -12,19 +12,20 @@ import ( ) const ( - debugBreakWaitCommandName = "wait-for-debug-break" - debugBreakStatusUserCommandName = "debug-break-status" - debugBreakStatusCommandName = "get-debug-break-status" - debugBreakClearStatusCommandName = "clear-debug-break-status" - debugBreakIDFlagName = "id" - debugBreakTimeoutFlagName = "timeout-seconds" - debugBreakDefaultTimeoutSeconds = 30 - debugBreakStatusProbeTimeout = 5 * time.Second - debugBreakStatusEnabled = "Enabled" - debugBreakStatusHit = "Hit" - debugBreakStatusNotEnabled = "NotEnabled" - debugBreakStatusExpired = "Expired" - debugBreakStatusCleared = "Cleared" + debugBreakWaitCommandName = "wait-for-debug-break" + debugBreakStatusUserCommandName = "debug-break-status" + debugBreakStatusCommandName = "get-debug-break-status" + debugBreakClearStatusCommandName = "clear-debug-break-status" + debugBreakIDFlagName = "id" + debugBreakTimeoutFlagName = "timeout-seconds" + debugBreakDefaultTimeoutSeconds = 30 + debugBreakStatusProbeTimeout = 5 * time.Second + debugBreakStatusEnabled = "Enabled" + debugBreakStatusHit = "Hit" + debugBreakStatusNotEnabled = "NotEnabled" + debugBreakStatusExpired = "Expired" + debugBreakStatusCleared = "Cleared" + debugBreakFinalStatusProbeTimeout = 250 * time.Millisecond ) var ( @@ -291,6 +292,16 @@ func waitForDebugBreak( if ctx.Err() != nil { return lastResponse, "", ctx.Err() } + finalResponse, finalState, hasFinalResponse, finalErr := queryDebugBreakStatusAtTimeout(ctx, connection, options.id) + if hasFinalResponse { + lastResponse = finalResponse + hasResponse = true + if finalState != "" { + return finalResponse, finalState, nil + } + } else if lastErr == nil { + lastErr = finalErr + } if hasResponse { return lastResponse, debugBreakWaitStateTimeout, nil } @@ -303,6 +314,22 @@ func waitForDebugBreak( } } +func queryDebugBreakStatusAtTimeout( + ctx context.Context, + connection unityipc.Connection, + id string, +) (debugBreakStatusResponse, debugBreakWaitState, bool, error) { + finalContext, cancel := context.WithTimeout(ctx, debugBreakFinalStatusProbeTimeout) + defer cancel() + + response, err := queryDebugBreakStatus(finalContext, connection, id) + if err != nil { + return debugBreakStatusResponse{}, "", false, err + } + + return response, debugBreakWaitStateForStatus(response.Status), true, nil +} + func debugBreakWaitStateForStatus(status string) debugBreakWaitState { switch status { case debugBreakStatusHit: diff --git a/cli/internal/cli/debug_break_wait_test.go b/cli/internal/cli/debug_break_wait_test.go index 1feff9867b..731ed864e4 100644 --- a/cli/internal/cli/debug_break_wait_test.go +++ b/cli/internal/cli/debug_break_wait_test.go @@ -132,6 +132,74 @@ func TestRunWaitForDebugBreakClearsEnabledMarkerAfterTimeout(t *testing.T) { } } +// Verifies wait-for-debug-break does one final status probe before treating timeout as missed. +func TestRunWaitForDebugBreakReturnsFinalHitAtTimeoutBoundary(t *testing.T) { + originalQuery := queryDebugBreakStatus + originalClear := clearDebugBreakStatus + defer func() { + queryDebugBreakStatus = originalQuery + clearDebugBreakStatus = originalClear + }() + + requestCount := 0 + queryDebugBreakStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (debugBreakStatusResponse, error) { + requestCount++ + if requestCount == 1 { + return debugBreakStatusResponse{ + Id: id, + Status: debugBreakStatusEnabled, + IsEnabled: true, + }, nil + } + return debugBreakStatusResponse{ + Id: id, + Status: debugBreakStatusHit, + IsHit: true, + IsPaused: true, + HitCount: 1, + }, nil + } + + clearedID := "" + clearDebugBreakStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (debugBreakStatusResponse, error) { + clearedID = id + return debugBreakStatusResponse{Id: id, Status: debugBreakStatusCleared}, nil + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runWaitForDebugBreak(context.Background(), unityipc.Connection{}, waitForDebugBreakOptions{ + id: "jump", + timeoutSeconds: 1, + timeout: 5 * time.Millisecond, + }, &stdout, &stderr) + + if code != 0 { + t.Fatalf("expected success, got %d with stderr %s", code, stderr.String()) + } + if clearedID != "" { + t.Fatalf("marker should not be cleared after final hit: %s", clearedID) + } + var response debugBreakStatusResponse + if err := json.Unmarshal(stdout.Bytes(), &response); err != nil { + t.Fatalf("stdout is not valid JSON: %v\n%s", err, stdout.String()) + } + if response.Status != debugBreakStatusHit || response.HitCount != 1 { + t.Fatalf("response mismatch: %#v", response) + } + if requestCount != 2 { + t.Fatalf("request count mismatch: %d", requestCount) + } +} + // Verifies wait-for-debug-break rejects calls before the marker is enabled. func TestWaitForDebugBreakReturnsNotEnabledStateImmediately(t *testing.T) { originalQuery := queryDebugBreakStatus From d64b441a2978a87a3aa2c5dfb3bcf86abf6c9dad Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 4 Jun 2026 08:49:57 +0900 Subject: [PATCH 18/26] Expose debug break native commands in tool settings Show wait-for-debug-break and debug-break-status alongside built-in tools, and make the native CLI dispatch respect disabled tool settings so the checkbox state matches command behavior. --- .../Tests/Editor/ToolSettingsUseCaseTests.cs | 37 +++++++++++++++++++ .../Editor/ToolSettingsUseCaseTests.cs.meta | 11 ++++++ .../UseCases/ToolSettingsUseCase.cs | 16 +++++++- .../ToolContracts/UnityCliLoopConstants.cs | 2 + cli/internal/cli/debug_break_wait_test.go | 27 ++++++++++++++ cli/internal/cli/error_envelope.go | 31 +--------------- cli/internal/cli/execution_errors.go | 31 ++++++++++++++++ cli/internal/cli/native_tool_settings.go | 25 +++++++++++++ cli/internal/cli/run.go | 5 +++ cli/internal/cli/skills_targets.go | 7 ++++ 10 files changed, 161 insertions(+), 31 deletions(-) create mode 100644 Assets/Tests/Editor/ToolSettingsUseCaseTests.cs create mode 100644 Assets/Tests/Editor/ToolSettingsUseCaseTests.cs.meta create mode 100644 cli/internal/cli/execution_errors.go create mode 100644 cli/internal/cli/native_tool_settings.go diff --git a/Assets/Tests/Editor/ToolSettingsUseCaseTests.cs b/Assets/Tests/Editor/ToolSettingsUseCaseTests.cs new file mode 100644 index 0000000000..97c200da14 --- /dev/null +++ b/Assets/Tests/Editor/ToolSettingsUseCaseTests.cs @@ -0,0 +1,37 @@ +using System.Linq; +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.Application; +using io.github.hatayama.UnityCliLoop.Domain; +using io.github.hatayama.UnityCliLoop.Infrastructure; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Test fixture that verifies tool settings use case catalog behavior. + /// + [TestFixture] + public class ToolSettingsUseCaseTests + { + [Test] + public void TryGetToolCatalog_WhenRegistryAvailable_IncludesNativeDebugBreakCommands() + { + // Verifies CLI-native debug break commands are user-toggleable built-in tools. + ToolSettingsService toolSettingsService = new(new ToolSettingsRepository()); + UnityCliLoopToolRegistrarService toolRegistrarService = new( + new EmptyInternalToolNameProvider(), + toolSettingsService, + new UnityCliLoopToolExecutionService()); + ToolSettingsUseCase useCase = new(toolSettingsService, toolRegistrarService); + useCase.WarmupRegistry(); + + bool isAvailable = useCase.TryGetToolCatalog(out ToolSettingsUseCase.ToolCatalogItem[] allTools); + string[] toolNames = allTools.Select(tool => tool.Name).ToArray(); + + Assert.That(isAvailable, Is.True); + Assert.That(toolNames, Does.Contain(UnityCliLoopConstants.COMMAND_NAME_WAIT_FOR_DEBUG_BREAK)); + Assert.That(toolNames, Does.Contain(UnityCliLoopConstants.COMMAND_NAME_DEBUG_BREAK_STATUS)); + } + } +} diff --git a/Assets/Tests/Editor/ToolSettingsUseCaseTests.cs.meta b/Assets/Tests/Editor/ToolSettingsUseCaseTests.cs.meta new file mode 100644 index 0000000000..b6504af015 --- /dev/null +++ b/Assets/Tests/Editor/ToolSettingsUseCaseTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 309b868e2332c41e085aa054a5961de2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Application/UseCases/ToolSettingsUseCase.cs b/Packages/src/Editor/Application/UseCases/ToolSettingsUseCase.cs index 10af05d75c..2e239a4ba6 100644 --- a/Packages/src/Editor/Application/UseCases/ToolSettingsUseCase.cs +++ b/Packages/src/Editor/Application/UseCases/ToolSettingsUseCase.cs @@ -3,11 +3,24 @@ using System.Linq; using io.github.hatayama.UnityCliLoop.Domain; +using io.github.hatayama.UnityCliLoop.ToolContracts; namespace io.github.hatayama.UnityCliLoop.Application { internal sealed class ToolSettingsUseCase { + private static readonly ToolCatalogItem[] NativeToolCatalogItems = + { + new( + UnityCliLoopConstants.COMMAND_NAME_WAIT_FOR_DEBUG_BREAK, + displayDevelopmentOnly: false, + isThirdParty: false), + new( + UnityCliLoopConstants.COMMAND_NAME_DEBUG_BREAK_STATUS, + displayDevelopmentOnly: false, + isThirdParty: false) + }; + private readonly ToolSettingsService _toolSettingsService; private readonly UnityCliLoopToolRegistrarService _toolRegistrarService; @@ -67,12 +80,13 @@ internal bool TryGetToolCatalog(out ToolCatalogItem[] allTools) return false; } - allTools = registry.GetToolSettingsCatalog() + ToolCatalogItem[] registryTools = registry.GetToolSettingsCatalog() .Select(item => new ToolCatalogItem( item.Name, item.DisplayDevelopmentOnly, item.IsThirdParty)) .ToArray(); + allTools = registryTools.Concat(NativeToolCatalogItems).ToArray(); return true; } diff --git a/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs b/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs index d9c5763287..b5eba119f9 100644 --- a/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs +++ b/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs @@ -70,6 +70,8 @@ public static UnityEditor.PackageManager.PackageInfo PackageInfo public const string COMMAND_NAME_GET_TOOL_DETAILS = "get-tool-details"; public const string COMMAND_NAME_GET_VERSION = "get-version"; public const string COMMAND_NAME_GET_COMPILE_STATUS = "get-compile-status"; + public const string COMMAND_NAME_WAIT_FOR_DEBUG_BREAK = "wait-for-debug-break"; + public const string COMMAND_NAME_DEBUG_BREAK_STATUS = "debug-break-status"; public const string COMMAND_NAME_GET_DEBUG_BREAK_STATUS = "get-debug-break-status"; public const string COMMAND_NAME_CLEAR_DEBUG_BREAK_STATUS = "clear-debug-break-status"; diff --git a/cli/internal/cli/debug_break_wait_test.go b/cli/internal/cli/debug_break_wait_test.go index 731ed864e4..a690ec5d57 100644 --- a/cli/internal/cli/debug_break_wait_test.go +++ b/cli/internal/cli/debug_break_wait_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "path/filepath" "strings" "testing" "time" @@ -299,6 +300,32 @@ func TestDebugBreakExpiredErrorReportsNoRemainingTime(t *testing.T) { } } +// Verifies disabled native debug-break commands are rejected before Unity dispatch. +func TestRunProjectLocalWaitForDebugBreakRespectsToolSettings(t *testing.T) { + projectRoot := createLaunchTestProject(t) + writeToolSettings(t, projectRoot, `{"disabledTools":["wait-for-debug-break"]}`) + t.Chdir(filepath.Dir(projectRoot)) + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := RunProjectLocal( + context.Background(), + []string{"--project-path", projectRoot, debugBreakWaitCommandName, "--id", "jump"}, + &stdout, + &stderr) + + if code != 1 { + t.Fatalf("expected disabled command failure, got %d with stdout %s", code, stdout.String()) + } + envelope := parseDebugBreakErrorEnvelope(t, stderr.Bytes()) + if envelope.Error.ErrorCode != errorCodeToolDisabled { + t.Fatalf("error code mismatch: %#v", envelope.Error) + } + if envelope.Error.Command != debugBreakWaitCommandName { + t.Fatalf("command mismatch: %#v", envelope.Error) + } +} + // Verifies wait-for-debug-break requires a marker id. func TestParseWaitForDebugBreakOptionsRequiresID(t *testing.T) { _, err := parseWaitForDebugBreakOptions([]string{"--timeout-seconds", "1"}) diff --git a/cli/internal/cli/error_envelope.go b/cli/internal/cli/error_envelope.go index 67574bde45..a2e4313b55 100644 --- a/cli/internal/cli/error_envelope.go +++ b/cli/internal/cli/error_envelope.go @@ -21,6 +21,7 @@ const ( errorCodeUnityRPCError = "UNITY_RPC_ERROR" errorCodeUnityServerBusy = "UNITY_SERVER_BUSY" errorCodeCLIUpdateRequired = "CLI_UPDATE_REQUIRED" + errorCodeToolDisabled = "TOOL_DISABLED" errorCodeCompileWaitTimeout = "COMPILE_WAIT_TIMEOUT" errorCodeControlPlayModeWaitTimeout = "CONTROL_PLAY_MODE_WAIT_TIMEOUT" errorCodeDebugBreakNotEnabled = "DEBUG_BREAK_NOT_ENABLED" @@ -443,36 +444,6 @@ func unknownCommandError(command string, cache toolsCache, context errorContext) } } -func compileWaitTimeoutError(projectRoot string) cliError { - return cliError{ - ErrorCode: errorCodeCompileWaitTimeout, - Phase: errorPhaseCompileWaiting, - Message: "Compile status wait timed out after 180000ms.", - Retryable: true, - SafeToRetry: true, - ProjectRoot: projectRoot, - Command: compileCommandName, - NextActions: []string{ - "Retry `uloop compile` after Unity becomes responsive.", - }, - } -} - -func internalCLIError(message string, context errorContext) cliError { - return cliError{ - ErrorCode: errorCodeInternalError, - Phase: errorPhaseExecution, - Message: message, - Retryable: false, - SafeToRetry: false, - ProjectRoot: context.projectRoot, - Command: context.command, - NextActions: []string{ - "Read the message and fix the local environment or command input before retrying.", - }, - } -} - func availableCommandNames(cache toolsCache) []string { seen := map[string]bool{} names := []string{} diff --git a/cli/internal/cli/execution_errors.go b/cli/internal/cli/execution_errors.go new file mode 100644 index 0000000000..a44c90549d --- /dev/null +++ b/cli/internal/cli/execution_errors.go @@ -0,0 +1,31 @@ +package cli + +func compileWaitTimeoutError(projectRoot string) cliError { + return cliError{ + ErrorCode: errorCodeCompileWaitTimeout, + Phase: errorPhaseCompileWaiting, + Message: "Compile status wait timed out after 180000ms.", + Retryable: true, + SafeToRetry: true, + ProjectRoot: projectRoot, + Command: compileCommandName, + NextActions: []string{ + "Retry `uloop compile` after Unity becomes responsive.", + }, + } +} + +func internalCLIError(message string, context errorContext) cliError { + return cliError{ + ErrorCode: errorCodeInternalError, + Phase: errorPhaseExecution, + Message: message, + Retryable: false, + SafeToRetry: false, + ProjectRoot: context.projectRoot, + Command: context.command, + NextActions: []string{ + "Read the message and fix the local environment or command input before retrying.", + }, + } +} diff --git a/cli/internal/cli/native_tool_settings.go b/cli/internal/cli/native_tool_settings.go new file mode 100644 index 0000000000..0ccf7f7b2a --- /dev/null +++ b/cli/internal/cli/native_tool_settings.go @@ -0,0 +1,25 @@ +package cli + +func isSettingsManagedNativeToolCommand(command string) bool { + switch command { + case debugBreakWaitCommandName, debugBreakStatusUserCommandName: + return true + default: + return false + } +} + +func nativeToolDisabledError(projectRoot string, command string) cliError { + return cliError{ + ErrorCode: errorCodeToolDisabled, + Phase: errorPhaseDispatch, + Message: "Tool is disabled in Unity CLI Loop settings: " + command, + Retryable: false, + SafeToRetry: false, + ProjectRoot: projectRoot, + Command: command, + NextActions: []string{ + "Enable the tool in Unity CLI Loop settings before running this command.", + }, + } +} diff --git a/cli/internal/cli/run.go b/cli/internal/cli/run.go index 20587d971d..5e02fe12d0 100644 --- a/cli/internal/cli/run.go +++ b/cli/internal/cli/run.go @@ -79,6 +79,11 @@ func RunProjectLocal(ctx context.Context, args []string, stdout io.Writer, stder writeClassifiedError(stderr, err, errorContext{command: command}) return 1 } + if isSettingsManagedNativeToolCommand(command) && + isToolDisabledByToolSettings(command, loadDisabledTools(connection.ProjectRoot)) { + writeErrorEnvelope(stderr, nativeToolDisabledError(connection.ProjectRoot, command)) + return 1 + } switch command { case "list": return runList(ctx, connection, stdout, stderr) diff --git a/cli/internal/cli/skills_targets.go b/cli/internal/cli/skills_targets.go index cd807bf736..647735e7dd 100644 --- a/cli/internal/cli/skills_targets.go +++ b/cli/internal/cli/skills_targets.go @@ -160,6 +160,13 @@ func isSkillDisabledByToolSettings(skill skillDefinition, disabledTools []string if toolName == "" { return false } + return isToolDisabledByToolSettings(toolName, disabledTools) +} + +func isToolDisabledByToolSettings(toolName string, disabledTools []string) bool { + if len(disabledTools) == 0 { + return false + } for _, disabledTool := range disabledTools { if disabledTool == toolName { return true From e2deb9d549b0c09650d1ff4c14c3c0b76d8b966f Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 4 Jun 2026 14:40:47 +0900 Subject: [PATCH 19/26] update skills --- .claude/skills/uloop-simulate-keyboard/SKILL.md | 4 +++- .claude/skills/uloop-simulate-mouse-input/SKILL.md | 4 +++- .claude/skills/uloop-wait-for-debug-break/SKILL.md | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.claude/skills/uloop-simulate-keyboard/SKILL.md b/.claude/skills/uloop-simulate-keyboard/SKILL.md index 0614d39c0c..fc65968875 100644 --- a/.claude/skills/uloop-simulate-keyboard/SKILL.md +++ b/.claude/skills/uloop-simulate-keyboard/SKILL.md @@ -48,7 +48,7 @@ Use `KeyDown` / `KeyUp` when the scenario intentionally needs a held key. - Use `UnityCliLoopDebug.Break("")` when a screenshot cannot prove that the keyboard input changed gameplay state, such as jump, sprint, or interaction. - Put the marker at a natural state transition after the game consumed the key, such as after jump velocity is applied, not immediately after sending `simulate-keyboard`. -- If the response has `InterruptedByDebugBreak: true`, Unity is paused for inspection and the tool released its held input bookkeeping. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. +- If the response has `InterruptedByDebugBreak: true`, Unity is paused for inspection and the tool released its held input bookkeeping. If a `UnityCliLoopDebug.Break` marker caused the pause, `DebugBreakId` and `DebugBreakHitCount` identify it. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. - Use distinct marker ids for strict phases, for example `jump-key-read` and `jump-velocity-applied`. ### KeyDown/KeyUp Rules @@ -93,6 +93,8 @@ Returns JSON with: - `Action` (string): The `--action` value that was applied (`Press`, `KeyDown`, or `KeyUp`) - `KeyName` (string, nullable): The key that was acted on; may be `null` when the action could not resolve a key - `InterruptedByDebugBreak` (boolean): True when Unity paused during Debug Break inspection and the input bookkeeping was safely released +- `DebugBreakId` (string, nullable): The marker id when a `UnityCliLoopDebug.Break` marker caused the interruption +- `DebugBreakHitCount` (integer, nullable): The marker hit count when a `UnityCliLoopDebug.Break` marker caused the interruption ## Prerequisites diff --git a/.claude/skills/uloop-simulate-mouse-input/SKILL.md b/.claude/skills/uloop-simulate-mouse-input/SKILL.md index 9e9b2f5357..78b3f0fa8c 100644 --- a/.claude/skills/uloop-simulate-mouse-input/SKILL.md +++ b/.claude/skills/uloop-simulate-mouse-input/SKILL.md @@ -52,7 +52,7 @@ uloop simulate-mouse-input --action [options] - Use `UnityCliLoopDebug.Break("")` when a screenshot cannot prove that mouse input changed gameplay state, such as block hit, camera turn, or item placement. - Put the marker at a natural state transition after the game consumed the mouse input, such as after a raycast hit, damage application, placement, or camera rotation update, not immediately after sending `simulate-mouse-input`. -- If the response has `InterruptedByDebugBreak: true`, Unity is paused for inspection and the tool released its held input bookkeeping. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. +- If the response has `InterruptedByDebugBreak: true`, Unity is paused for inspection and the tool released its held input bookkeeping. If a `UnityCliLoopDebug.Break` marker caused the pause, `DebugBreakId` and `DebugBreakHitCount` identify it. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. - Use distinct marker ids for strict phases, for example `block-raycast-hit` and `block-damage-applied`. ### Global Options (optional) @@ -115,5 +115,7 @@ Returns JSON with: - `PositionX`: Target X coordinate (nullable float; populated for `Click` / `LongPress`) - `PositionY`: Target Y coordinate (nullable float; populated for `Click` / `LongPress`) - `InterruptedByDebugBreak`: True when Unity paused during Debug Break inspection and the input bookkeeping was safely released +- `DebugBreakId`: The marker id when a `UnityCliLoopDebug.Break` marker caused the interruption +- `DebugBreakHitCount`: The marker hit count when a `UnityCliLoopDebug.Break` marker caused the interruption There is no `DeltaX`, `DeltaY`, `ScrollX`, `ScrollY`, `Duration`, or hit-element field in the response — only the issued action, button, target position, and Debug Break interruption state are echoed back. Verify visual outcome with a follow-up screenshot. diff --git a/.claude/skills/uloop-wait-for-debug-break/SKILL.md b/.claude/skills/uloop-wait-for-debug-break/SKILL.md index 97b4ec7730..e2fdbb5a9b 100644 --- a/.claude/skills/uloop-wait-for-debug-break/SKILL.md +++ b/.claude/skills/uloop-wait-for-debug-break/SKILL.md @@ -43,7 +43,7 @@ uloop debug-break-status --id player-jumped uloop wait-for-debug-break --id player-jumped --timeout-seconds 30 ``` -If this command times out, the marker line was not reached while the command waited. Inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, and `remainingMilliseconds` to distinguish input not being consumed, gameplay conditions not being met, an id mismatch, or Unity already being paused. +If this command times out, the marker line was not reached while the command waited. Inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, gameplay conditions not being met, an id mismatch, or Unity already being paused. `elapsedSinceEnabledMilliseconds` is measured from `enable-debug-break`, not from `wait-for-debug-break`. 7. While Unity is paused, inspect state with `uloop get-logs`, `uloop get-hierarchy`, `uloop find-game-objects`, screenshots, or `uloop execute-dynamic-code`. 8. Clear the marker if you stop waiting: From b39152093470cc0a6e4632de4ef4aedf65de3af0 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 4 Jun 2026 15:48:13 +0900 Subject: [PATCH 20/26] Capture editor state for busy responses Keep the latest play/pause state on the main thread so busy responses created before the request reaches the main thread can still include diagnostics. Also document the New Input System prerequisite for debug-break workflows that use simulated input. --- .../uloop-wait-for-debug-break/SKILL.md | 4 ++ .../uloop-wait-for-debug-break/SKILL.md | 4 ++ .../UnityCliLoopToolExecutionServiceTests.cs | 12 ++-- .../Application/ApplicationEditorStartup.cs | 1 + .../UnityCliLoopEditorStateSnapshot.cs | 72 +++++++++++++++++++ .../UnityCliLoopEditorStateSnapshot.cs.meta | 11 +++ .../UnityCliLoopToolExecutionService.cs | 25 +++++-- .../CliOnlyTools~/PausePoint/Skill/SKILL.md | 4 ++ 8 files changed, 122 insertions(+), 11 deletions(-) create mode 100644 Packages/src/Editor/Application/UnityCliLoopEditorStateSnapshot.cs create mode 100644 Packages/src/Editor/Application/UnityCliLoopEditorStateSnapshot.cs.meta diff --git a/.agents/skills/uloop-wait-for-debug-break/SKILL.md b/.agents/skills/uloop-wait-for-debug-break/SKILL.md index e2fdbb5a9b..65ea3b9f9d 100644 --- a/.agents/skills/uloop-wait-for-debug-break/SKILL.md +++ b/.agents/skills/uloop-wait-for-debug-break/SKILL.md @@ -13,6 +13,10 @@ Pause Unity when execution reaches a named marker in user code. - Use during development debugging, not only E2E tests. - Use after simulated keyboard or mouse input when you need to inspect exact runtime state. +## Assumptions + +- `simulate-keyboard` and `simulate-mouse-input` require Player Settings > Active Input Handling to be `Input System Package (New)` or `Both`. + ## Workflow 1. Add a marker at the state you want to inspect: diff --git a/.claude/skills/uloop-wait-for-debug-break/SKILL.md b/.claude/skills/uloop-wait-for-debug-break/SKILL.md index e2fdbb5a9b..65ea3b9f9d 100644 --- a/.claude/skills/uloop-wait-for-debug-break/SKILL.md +++ b/.claude/skills/uloop-wait-for-debug-break/SKILL.md @@ -13,6 +13,10 @@ Pause Unity when execution reaches a named marker in user code. - Use during development debugging, not only E2E tests. - Use after simulated keyboard or mouse input when you need to inspect exact runtime state. +## Assumptions + +- `simulate-keyboard` and `simulate-mouse-input` require Player Settings > Active Input Handling to be `Input System Package (New)` or `Both`. + ## Workflow 1. Add a marker at the state you want to inspect: diff --git a/Assets/Tests/Editor/UnityCliLoopToolExecutionServiceTests.cs b/Assets/Tests/Editor/UnityCliLoopToolExecutionServiceTests.cs index 9203b3b3f6..dfe04781c3 100644 --- a/Assets/Tests/Editor/UnityCliLoopToolExecutionServiceTests.cs +++ b/Assets/Tests/Editor/UnityCliLoopToolExecutionServiceTests.cs @@ -11,11 +11,14 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor public sealed class UnityCliLoopToolExecutionServiceTests { [Test] - public void CreateBusyException_WhenDispatcherReportsBackgroundThread_DoesNotCaptureEditorState() + public void CreateBusyException_WhenDispatcherReportsBackgroundThread_UsesEditorStateSnapshot() { - // Verifies busy responses can be created before the request reaches Unity's main thread. + // Verifies busy responses include the last editor state even before the request reaches Unity's main thread. CapturingMainThreadDispatcher dispatcher = new CapturingMainThreadDispatcher(); MainThreadSwitcher.RegisterService(dispatcher); + UnityCliLoopEditorStateSnapshot.SetPlayStateForTesting( + isPlaying: true, + isPaused: true); try { @@ -24,11 +27,12 @@ public void CreateBusyException_WhenDispatcherReportsBackgroundThread_DoesNotCap Assert.That(exception.RunningToolName, Is.EqualTo("running-tool")); Assert.That(exception.RequestedToolName, Is.EqualTo("requested-tool")); - Assert.That(exception.IsPlaying, Is.Null); - Assert.That(exception.IsPaused, Is.Null); + Assert.That(exception.IsPlaying, Is.True); + Assert.That(exception.IsPaused, Is.True); } finally { + UnityCliLoopEditorStateSnapshot.ClearForTesting(); RestoreEditorMainThreadDispatcher(); } } diff --git a/Packages/src/Editor/Application/ApplicationEditorStartup.cs b/Packages/src/Editor/Application/ApplicationEditorStartup.cs index 893c52ebbc..84907b3a35 100644 --- a/Packages/src/Editor/Application/ApplicationEditorStartup.cs +++ b/Packages/src/Editor/Application/ApplicationEditorStartup.cs @@ -17,6 +17,7 @@ internal static void Initialize(IDomainReloadDetectionService domainReloadDetect UnityCliLoopEditorDomainReloadStateRegistration.RegisterForEditorStartup(); MainThreadSwitcher.InitializeForEditorStartup(); + UnityCliLoopEditorStateSnapshot.InitializeForEditorStartup(); EditorDelayManager.InitializeForEditorStartup(); domainReloadDetectionService.RegisterForEditorStartup(); } diff --git a/Packages/src/Editor/Application/UnityCliLoopEditorStateSnapshot.cs b/Packages/src/Editor/Application/UnityCliLoopEditorStateSnapshot.cs new file mode 100644 index 0000000000..b7350efdf7 --- /dev/null +++ b/Packages/src/Editor/Application/UnityCliLoopEditorStateSnapshot.cs @@ -0,0 +1,72 @@ +using UnityEditor; + +namespace io.github.hatayama.UnityCliLoop.Application +{ + /// + /// Stores the latest Unity play state for error responses created outside the main thread. + /// + internal static class UnityCliLoopEditorStateSnapshot + { + private static readonly object StateLock = new(); + private static bool _hasPlayState; + private static bool _isPlaying; + private static bool _isPaused; + + internal static void InitializeForEditorStartup() + { + EditorApplication.update -= RefreshFromEditor; + EditorApplication.update += RefreshFromEditor; + EditorApplication.playModeStateChanged -= OnPlayModeStateChanged; + EditorApplication.playModeStateChanged += OnPlayModeStateChanged; + RefreshFromEditor(); + } + + internal static (bool HasValue, bool IsPlaying, bool IsPaused) GetPlayState() + { + lock (StateLock) + { + return ( + HasValue: _hasPlayState, + IsPlaying: _isPlaying, + IsPaused: _isPaused); + } + } + + internal static void SetPlayStateForTesting(bool isPlaying, bool isPaused) + { + SetPlayState(isPlaying, isPaused); + } + + internal static void ClearForTesting() + { + lock (StateLock) + { + _hasPlayState = false; + _isPlaying = false; + _isPaused = false; + } + } + + private static void OnPlayModeStateChanged(PlayModeStateChange state) + { + RefreshFromEditor(); + } + + private static void RefreshFromEditor() + { + SetPlayState( + EditorApplication.isPlaying, + EditorApplication.isPaused); + } + + private static void SetPlayState(bool isPlaying, bool isPaused) + { + lock (StateLock) + { + _hasPlayState = true; + _isPlaying = isPlaying; + _isPaused = isPaused; + } + } + } +} diff --git a/Packages/src/Editor/Application/UnityCliLoopEditorStateSnapshot.cs.meta b/Packages/src/Editor/Application/UnityCliLoopEditorStateSnapshot.cs.meta new file mode 100644 index 0000000000..29b89386e6 --- /dev/null +++ b/Packages/src/Editor/Application/UnityCliLoopEditorStateSnapshot.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f988033516bec4782ae73a71c45d1861 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Application/UnityCliLoopToolExecutionService.cs b/Packages/src/Editor/Application/UnityCliLoopToolExecutionService.cs index 3df3bfe2f5..5d67695078 100644 --- a/Packages/src/Editor/Application/UnityCliLoopToolExecutionService.cs +++ b/Packages/src/Editor/Application/UnityCliLoopToolExecutionService.cs @@ -125,16 +125,27 @@ internal static UnityCliLoopToolBusyException CreateBusyException( Debug.Assert(!string.IsNullOrWhiteSpace(runningToolName), "runningToolName must not be null or whitespace"); Debug.Assert(!string.IsNullOrWhiteSpace(requestedToolName), "requestedToolName must not be null or whitespace"); - if (!MainThreadSwitcher.IsMainThread) + if (MainThreadSwitcher.IsMainThread) { - return new UnityCliLoopToolBusyException(runningToolName, requestedToolName); + return new UnityCliLoopToolBusyException( + runningToolName, + requestedToolName, + EditorApplication.isPlaying, + EditorApplication.isPaused); } - return new UnityCliLoopToolBusyException( - runningToolName, - requestedToolName, - EditorApplication.isPlaying, - EditorApplication.isPaused); + (bool HasValue, bool IsPlaying, bool IsPaused) playState = + UnityCliLoopEditorStateSnapshot.GetPlayState(); + if (playState.HasValue) + { + return new UnityCliLoopToolBusyException( + runningToolName, + requestedToolName, + playState.IsPlaying, + playState.IsPaused); + } + + return new UnityCliLoopToolBusyException(runningToolName, requestedToolName); } private string GetRunningToolNameInsideLock() diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md index e2fdbb5a9b..65ea3b9f9d 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -13,6 +13,10 @@ Pause Unity when execution reaches a named marker in user code. - Use during development debugging, not only E2E tests. - Use after simulated keyboard or mouse input when you need to inspect exact runtime state. +## Assumptions + +- `simulate-keyboard` and `simulate-mouse-input` require Player Settings > Active Input Handling to be `Input System Package (New)` or `Both`. + ## Workflow 1. Add a marker at the state you want to inspect: From 65ee0a272c729096f81132f0a9751f16eda06cde Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 4 Jun 2026 17:34:35 +0900 Subject: [PATCH 21/26] Remove input setting assumption from debug break skill Avoid prompting agents to change Player Settings while following the wait-for-debug-break workflow. Keep the generated skill copies in sync with the source skill. --- .agents/skills/uloop-wait-for-debug-break/SKILL.md | 4 ---- .claude/skills/uloop-wait-for-debug-break/SKILL.md | 4 ---- Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md | 4 ---- 3 files changed, 12 deletions(-) diff --git a/.agents/skills/uloop-wait-for-debug-break/SKILL.md b/.agents/skills/uloop-wait-for-debug-break/SKILL.md index 65ea3b9f9d..e2fdbb5a9b 100644 --- a/.agents/skills/uloop-wait-for-debug-break/SKILL.md +++ b/.agents/skills/uloop-wait-for-debug-break/SKILL.md @@ -13,10 +13,6 @@ Pause Unity when execution reaches a named marker in user code. - Use during development debugging, not only E2E tests. - Use after simulated keyboard or mouse input when you need to inspect exact runtime state. -## Assumptions - -- `simulate-keyboard` and `simulate-mouse-input` require Player Settings > Active Input Handling to be `Input System Package (New)` or `Both`. - ## Workflow 1. Add a marker at the state you want to inspect: diff --git a/.claude/skills/uloop-wait-for-debug-break/SKILL.md b/.claude/skills/uloop-wait-for-debug-break/SKILL.md index 65ea3b9f9d..e2fdbb5a9b 100644 --- a/.claude/skills/uloop-wait-for-debug-break/SKILL.md +++ b/.claude/skills/uloop-wait-for-debug-break/SKILL.md @@ -13,10 +13,6 @@ Pause Unity when execution reaches a named marker in user code. - Use during development debugging, not only E2E tests. - Use after simulated keyboard or mouse input when you need to inspect exact runtime state. -## Assumptions - -- `simulate-keyboard` and `simulate-mouse-input` require Player Settings > Active Input Handling to be `Input System Package (New)` or `Both`. - ## Workflow 1. Add a marker at the state you want to inspect: diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md index 65ea3b9f9d..e2fdbb5a9b 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -13,10 +13,6 @@ Pause Unity when execution reaches a named marker in user code. - Use during development debugging, not only E2E tests. - Use after simulated keyboard or mouse input when you need to inspect exact runtime state. -## Assumptions - -- `simulate-keyboard` and `simulate-mouse-input` require Player Settings > Active Input Handling to be `Input System Package (New)` or `Both`. - ## Workflow 1. Add a marker at the state you want to inspect: From 025b24bf12005986e4e74921500e16edcd9f25ab Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 4 Jun 2026 19:00:41 +0900 Subject: [PATCH 22/26] Clarify debug break skill trigger Describe wait-for-debug-break as a runtime checkpoint tool so agents consider it for gameplay, UI, async, physics, and state transitions instead of only simulated input checks. --- .agents/skills/uloop-wait-for-debug-break/SKILL.md | 2 +- .claude/skills/uloop-wait-for-debug-break/SKILL.md | 2 +- Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.agents/skills/uloop-wait-for-debug-break/SKILL.md b/.agents/skills/uloop-wait-for-debug-break/SKILL.md index e2fdbb5a9b..34450c3010 100644 --- a/.agents/skills/uloop-wait-for-debug-break/SKILL.md +++ b/.agents/skills/uloop-wait-for-debug-break/SKILL.md @@ -1,6 +1,6 @@ --- name: uloop-wait-for-debug-break -description: "Pause Unity at a named UnityCliLoopDebug.Break marker when input or gameplay state is hard to verify." +description: "Pause Unity at a named runtime checkpoint when logs, screenshots, polling, or timing checks cannot prove a gameplay, UI, async, physics, or state transition." --- # uloop wait-for-debug-break diff --git a/.claude/skills/uloop-wait-for-debug-break/SKILL.md b/.claude/skills/uloop-wait-for-debug-break/SKILL.md index e2fdbb5a9b..34450c3010 100644 --- a/.claude/skills/uloop-wait-for-debug-break/SKILL.md +++ b/.claude/skills/uloop-wait-for-debug-break/SKILL.md @@ -1,6 +1,6 @@ --- name: uloop-wait-for-debug-break -description: "Pause Unity at a named UnityCliLoopDebug.Break marker when input or gameplay state is hard to verify." +description: "Pause Unity at a named runtime checkpoint when logs, screenshots, polling, or timing checks cannot prove a gameplay, UI, async, physics, or state transition." --- # uloop wait-for-debug-break diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md index e2fdbb5a9b..34450c3010 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -1,6 +1,6 @@ --- name: uloop-wait-for-debug-break -description: "Pause Unity at a named UnityCliLoopDebug.Break marker when input or gameplay state is hard to verify." +description: "Pause Unity at a named runtime checkpoint when logs, screenshots, polling, or timing checks cannot prove a gameplay, UI, async, physics, or state transition." --- # uloop wait-for-debug-break From 4a0b1dd51b65ccb95a8a1463dd8ccee0f8e93ce1 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 4 Jun 2026 20:28:34 +0900 Subject: [PATCH 23/26] Encourage proactive debug break skill use Describe wait-for-debug-break as a normal PlayMode E2E checkpoint tool instead of a fallback for failed screenshots, and sync the generated skill copies. --- .agents/skills/uloop-wait-for-debug-break/SKILL.md | 9 ++++++--- .claude/skills/uloop-wait-for-debug-break/SKILL.md | 9 ++++++--- .../src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md | 9 ++++++--- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/.agents/skills/uloop-wait-for-debug-break/SKILL.md b/.agents/skills/uloop-wait-for-debug-break/SKILL.md index 34450c3010..3b46a3189c 100644 --- a/.agents/skills/uloop-wait-for-debug-break/SKILL.md +++ b/.agents/skills/uloop-wait-for-debug-break/SKILL.md @@ -1,6 +1,6 @@ --- name: uloop-wait-for-debug-break -description: "Pause Unity at a named runtime checkpoint when logs, screenshots, polling, or timing checks cannot prove a gameplay, UI, async, physics, or state transition." +description: "Pause Unity at named runtime checkpoints to prove transient PlayMode state changes after input, physics, async, or UI events." --- # uloop wait-for-debug-break @@ -9,9 +9,11 @@ Pause Unity when execution reaches a named marker in user code. ## When to use -- Use when logs or screenshots cannot prove that a gameplay path was reached. +- Use proactively when a PlayMode E2E depends on simulated input causing a runtime state transition. +- Use it alongside screenshots and logs; it is not only for cases where screenshots fail. +- Good checkpoints include jump applied, launch complete, collision handled, score changed, line cleared, block mined, item placed, game over shown, or UI updated after input. +- If you would otherwise poll with `execute-dynamic-code` or sleep after simulated input, prefer `wait-for-debug-break` at a named post-transition checkpoint. - Use during development debugging, not only E2E tests. -- Use after simulated keyboard or mouse input when you need to inspect exact runtime state. ## Workflow @@ -55,6 +57,7 @@ uloop clear-debug-break --id player-jumped ## Marker placement - Prefer natural gameplay or state-transition locations after input has been consumed, such as after jump velocity/state changes, physics contact, damage application, or inventory mutation. +- Enable markers after Play Mode is running, and prefer checkpoints reached after the triggering input command can return to avoid domain reload loss or tool Busy states. - Avoid placing the marker immediately after issuing simulated input unless that exact input handling line is the state you need to inspect; immediate markers can interrupt the input command before the resulting gameplay state settles. - Use separate ids for strict phases, for example `jump-input-read`, `jump-velocity-applied`, and `jump-landed`, instead of reusing one broad marker. diff --git a/.claude/skills/uloop-wait-for-debug-break/SKILL.md b/.claude/skills/uloop-wait-for-debug-break/SKILL.md index 34450c3010..3b46a3189c 100644 --- a/.claude/skills/uloop-wait-for-debug-break/SKILL.md +++ b/.claude/skills/uloop-wait-for-debug-break/SKILL.md @@ -1,6 +1,6 @@ --- name: uloop-wait-for-debug-break -description: "Pause Unity at a named runtime checkpoint when logs, screenshots, polling, or timing checks cannot prove a gameplay, UI, async, physics, or state transition." +description: "Pause Unity at named runtime checkpoints to prove transient PlayMode state changes after input, physics, async, or UI events." --- # uloop wait-for-debug-break @@ -9,9 +9,11 @@ Pause Unity when execution reaches a named marker in user code. ## When to use -- Use when logs or screenshots cannot prove that a gameplay path was reached. +- Use proactively when a PlayMode E2E depends on simulated input causing a runtime state transition. +- Use it alongside screenshots and logs; it is not only for cases where screenshots fail. +- Good checkpoints include jump applied, launch complete, collision handled, score changed, line cleared, block mined, item placed, game over shown, or UI updated after input. +- If you would otherwise poll with `execute-dynamic-code` or sleep after simulated input, prefer `wait-for-debug-break` at a named post-transition checkpoint. - Use during development debugging, not only E2E tests. -- Use after simulated keyboard or mouse input when you need to inspect exact runtime state. ## Workflow @@ -55,6 +57,7 @@ uloop clear-debug-break --id player-jumped ## Marker placement - Prefer natural gameplay or state-transition locations after input has been consumed, such as after jump velocity/state changes, physics contact, damage application, or inventory mutation. +- Enable markers after Play Mode is running, and prefer checkpoints reached after the triggering input command can return to avoid domain reload loss or tool Busy states. - Avoid placing the marker immediately after issuing simulated input unless that exact input handling line is the state you need to inspect; immediate markers can interrupt the input command before the resulting gameplay state settles. - Use separate ids for strict phases, for example `jump-input-read`, `jump-velocity-applied`, and `jump-landed`, instead of reusing one broad marker. diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md index 34450c3010..3b46a3189c 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -1,6 +1,6 @@ --- name: uloop-wait-for-debug-break -description: "Pause Unity at a named runtime checkpoint when logs, screenshots, polling, or timing checks cannot prove a gameplay, UI, async, physics, or state transition." +description: "Pause Unity at named runtime checkpoints to prove transient PlayMode state changes after input, physics, async, or UI events." --- # uloop wait-for-debug-break @@ -9,9 +9,11 @@ Pause Unity when execution reaches a named marker in user code. ## When to use -- Use when logs or screenshots cannot prove that a gameplay path was reached. +- Use proactively when a PlayMode E2E depends on simulated input causing a runtime state transition. +- Use it alongside screenshots and logs; it is not only for cases where screenshots fail. +- Good checkpoints include jump applied, launch complete, collision handled, score changed, line cleared, block mined, item placed, game over shown, or UI updated after input. +- If you would otherwise poll with `execute-dynamic-code` or sleep after simulated input, prefer `wait-for-debug-break` at a named post-transition checkpoint. - Use during development debugging, not only E2E tests. -- Use after simulated keyboard or mouse input when you need to inspect exact runtime state. ## Workflow @@ -55,6 +57,7 @@ uloop clear-debug-break --id player-jumped ## Marker placement - Prefer natural gameplay or state-transition locations after input has been consumed, such as after jump velocity/state changes, physics contact, damage application, or inventory mutation. +- Enable markers after Play Mode is running, and prefer checkpoints reached after the triggering input command can return to avoid domain reload loss or tool Busy states. - Avoid placing the marker immediately after issuing simulated input unless that exact input handling line is the state you need to inspect; immediate markers can interrupt the input command before the resulting gameplay state settles. - Use separate ids for strict phases, for example `jump-input-read`, `jump-velocity-applied`, and `jump-landed`, instead of reusing one broad marker. From b45a26a0c9ec31a1fb17e535e5172b49c75fbdf9 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 4 Jun 2026 20:38:48 +0900 Subject: [PATCH 24/26] Broaden debug break skill trigger Include frame-specific PlayMode bugs in the wait-for-debug-break trigger so agents use it for development debugging as well as E2E transition proof. --- .agents/skills/uloop-wait-for-debug-break/SKILL.md | 7 ++++--- .claude/skills/uloop-wait-for-debug-break/SKILL.md | 7 ++++--- .../src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md | 7 ++++--- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/.agents/skills/uloop-wait-for-debug-break/SKILL.md b/.agents/skills/uloop-wait-for-debug-break/SKILL.md index 3b46a3189c..5a65285dd3 100644 --- a/.agents/skills/uloop-wait-for-debug-break/SKILL.md +++ b/.agents/skills/uloop-wait-for-debug-break/SKILL.md @@ -1,6 +1,6 @@ --- name: uloop-wait-for-debug-break -description: "Pause Unity at named runtime checkpoints to prove transient PlayMode state changes after input, physics, async, or UI events." +description: "Pause Unity at named checkpoints for frame-specific PlayMode bugs and transient input, physics, async, or UI state changes." --- # uloop wait-for-debug-break @@ -9,11 +9,11 @@ Pause Unity when execution reaches a named marker in user code. ## When to use -- Use proactively when a PlayMode E2E depends on simulated input causing a runtime state transition. +- Use when a bug appears only on a specific frame, timing window, animation step, physics tick, or `Update` / `FixedUpdate` branch. +- Use proactively when PlayMode E2E or development debugging depends on proving a transient runtime state transition. - Use it alongside screenshots and logs; it is not only for cases where screenshots fail. - Good checkpoints include jump applied, launch complete, collision handled, score changed, line cleared, block mined, item placed, game over shown, or UI updated after input. - If you would otherwise poll with `execute-dynamic-code` or sleep after simulated input, prefer `wait-for-debug-break` at a named post-transition checkpoint. -- Use during development debugging, not only E2E tests. ## Workflow @@ -57,6 +57,7 @@ uloop clear-debug-break --id player-jumped ## Marker placement - Prefer natural gameplay or state-transition locations after input has been consumed, such as after jump velocity/state changes, physics contact, damage application, or inventory mutation. +- For frame-specific bugs, place the marker on the suspicious state branch or immediately after the state mutation you need to freeze. - Enable markers after Play Mode is running, and prefer checkpoints reached after the triggering input command can return to avoid domain reload loss or tool Busy states. - Avoid placing the marker immediately after issuing simulated input unless that exact input handling line is the state you need to inspect; immediate markers can interrupt the input command before the resulting gameplay state settles. - Use separate ids for strict phases, for example `jump-input-read`, `jump-velocity-applied`, and `jump-landed`, instead of reusing one broad marker. diff --git a/.claude/skills/uloop-wait-for-debug-break/SKILL.md b/.claude/skills/uloop-wait-for-debug-break/SKILL.md index 3b46a3189c..5a65285dd3 100644 --- a/.claude/skills/uloop-wait-for-debug-break/SKILL.md +++ b/.claude/skills/uloop-wait-for-debug-break/SKILL.md @@ -1,6 +1,6 @@ --- name: uloop-wait-for-debug-break -description: "Pause Unity at named runtime checkpoints to prove transient PlayMode state changes after input, physics, async, or UI events." +description: "Pause Unity at named checkpoints for frame-specific PlayMode bugs and transient input, physics, async, or UI state changes." --- # uloop wait-for-debug-break @@ -9,11 +9,11 @@ Pause Unity when execution reaches a named marker in user code. ## When to use -- Use proactively when a PlayMode E2E depends on simulated input causing a runtime state transition. +- Use when a bug appears only on a specific frame, timing window, animation step, physics tick, or `Update` / `FixedUpdate` branch. +- Use proactively when PlayMode E2E or development debugging depends on proving a transient runtime state transition. - Use it alongside screenshots and logs; it is not only for cases where screenshots fail. - Good checkpoints include jump applied, launch complete, collision handled, score changed, line cleared, block mined, item placed, game over shown, or UI updated after input. - If you would otherwise poll with `execute-dynamic-code` or sleep after simulated input, prefer `wait-for-debug-break` at a named post-transition checkpoint. -- Use during development debugging, not only E2E tests. ## Workflow @@ -57,6 +57,7 @@ uloop clear-debug-break --id player-jumped ## Marker placement - Prefer natural gameplay or state-transition locations after input has been consumed, such as after jump velocity/state changes, physics contact, damage application, or inventory mutation. +- For frame-specific bugs, place the marker on the suspicious state branch or immediately after the state mutation you need to freeze. - Enable markers after Play Mode is running, and prefer checkpoints reached after the triggering input command can return to avoid domain reload loss or tool Busy states. - Avoid placing the marker immediately after issuing simulated input unless that exact input handling line is the state you need to inspect; immediate markers can interrupt the input command before the resulting gameplay state settles. - Use separate ids for strict phases, for example `jump-input-read`, `jump-velocity-applied`, and `jump-landed`, instead of reusing one broad marker. diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md index 3b46a3189c..5a65285dd3 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -1,6 +1,6 @@ --- name: uloop-wait-for-debug-break -description: "Pause Unity at named runtime checkpoints to prove transient PlayMode state changes after input, physics, async, or UI events." +description: "Pause Unity at named checkpoints for frame-specific PlayMode bugs and transient input, physics, async, or UI state changes." --- # uloop wait-for-debug-break @@ -9,11 +9,11 @@ Pause Unity when execution reaches a named marker in user code. ## When to use -- Use proactively when a PlayMode E2E depends on simulated input causing a runtime state transition. +- Use when a bug appears only on a specific frame, timing window, animation step, physics tick, or `Update` / `FixedUpdate` branch. +- Use proactively when PlayMode E2E or development debugging depends on proving a transient runtime state transition. - Use it alongside screenshots and logs; it is not only for cases where screenshots fail. - Good checkpoints include jump applied, launch complete, collision handled, score changed, line cleared, block mined, item placed, game over shown, or UI updated after input. - If you would otherwise poll with `execute-dynamic-code` or sleep after simulated input, prefer `wait-for-debug-break` at a named post-transition checkpoint. -- Use during development debugging, not only E2E tests. ## Workflow @@ -57,6 +57,7 @@ uloop clear-debug-break --id player-jumped ## Marker placement - Prefer natural gameplay or state-transition locations after input has been consumed, such as after jump velocity/state changes, physics contact, damage application, or inventory mutation. +- For frame-specific bugs, place the marker on the suspicious state branch or immediately after the state mutation you need to freeze. - Enable markers after Play Mode is running, and prefer checkpoints reached after the triggering input command can return to avoid domain reload loss or tool Busy states. - Avoid placing the marker immediately after issuing simulated input unless that exact input handling line is the state you need to inspect; immediate markers can interrupt the input command before the resulting gameplay state settles. - Use separate ids for strict phases, for example `jump-input-read`, `jump-velocity-applied`, and `jump-landed`, instead of reusing one broad marker. From 3319a32506b1d5373e642b302de6c09cda44c2a9 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 4 Jun 2026 21:18:51 +0900 Subject: [PATCH 25/26] Prefer debug break over transient polling Guide agents to use wait-for-debug-break when they would otherwise rely on sleep, repeated polling, or a second state read for transient PlayMode state. --- .agents/skills/uloop-wait-for-debug-break/SKILL.md | 2 +- .claude/skills/uloop-wait-for-debug-break/SKILL.md | 2 +- Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.agents/skills/uloop-wait-for-debug-break/SKILL.md b/.agents/skills/uloop-wait-for-debug-break/SKILL.md index 5a65285dd3..4c4bafc80e 100644 --- a/.agents/skills/uloop-wait-for-debug-break/SKILL.md +++ b/.agents/skills/uloop-wait-for-debug-break/SKILL.md @@ -13,7 +13,7 @@ Pause Unity when execution reaches a named marker in user code. - Use proactively when PlayMode E2E or development debugging depends on proving a transient runtime state transition. - Use it alongside screenshots and logs; it is not only for cases where screenshots fail. - Good checkpoints include jump applied, launch complete, collision handled, score changed, line cleared, block mined, item placed, game over shown, or UI updated after input. -- If you would otherwise poll with `execute-dynamic-code` or sleep after simulated input, prefer `wait-for-debug-break` at a named post-transition checkpoint. +- If you use sleep, repeated polling, or a second state read to catch a transient PlayMode state, prefer `wait-for-debug-break` instead. ## Workflow diff --git a/.claude/skills/uloop-wait-for-debug-break/SKILL.md b/.claude/skills/uloop-wait-for-debug-break/SKILL.md index 5a65285dd3..4c4bafc80e 100644 --- a/.claude/skills/uloop-wait-for-debug-break/SKILL.md +++ b/.claude/skills/uloop-wait-for-debug-break/SKILL.md @@ -13,7 +13,7 @@ Pause Unity when execution reaches a named marker in user code. - Use proactively when PlayMode E2E or development debugging depends on proving a transient runtime state transition. - Use it alongside screenshots and logs; it is not only for cases where screenshots fail. - Good checkpoints include jump applied, launch complete, collision handled, score changed, line cleared, block mined, item placed, game over shown, or UI updated after input. -- If you would otherwise poll with `execute-dynamic-code` or sleep after simulated input, prefer `wait-for-debug-break` at a named post-transition checkpoint. +- If you use sleep, repeated polling, or a second state read to catch a transient PlayMode state, prefer `wait-for-debug-break` instead. ## Workflow diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md index 5a65285dd3..4c4bafc80e 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -13,7 +13,7 @@ Pause Unity when execution reaches a named marker in user code. - Use proactively when PlayMode E2E or development debugging depends on proving a transient runtime state transition. - Use it alongside screenshots and logs; it is not only for cases where screenshots fail. - Good checkpoints include jump applied, launch complete, collision handled, score changed, line cleared, block mined, item placed, game over shown, or UI updated after input. -- If you would otherwise poll with `execute-dynamic-code` or sleep after simulated input, prefer `wait-for-debug-break` at a named post-transition checkpoint. +- If you use sleep, repeated polling, or a second state read to catch a transient PlayMode state, prefer `wait-for-debug-break` instead. ## Workflow From 8a3eb5e02e262aa657ecda6c7ce70f1a802e0403 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 4 Jun 2026 21:23:52 +0900 Subject: [PATCH 26/26] Bump CLI requirement for debug break commands Require CLI 3.0.0-beta.26 so the Unity package rejects older native CLIs that do not include the debug-break command contract. --- Assets/Tests/Editor/CliSetupApplicationServiceTests.cs | 4 ++-- Packages/src/Editor/Domain/CliConstants.cs | 2 +- cli/contract.json | 2 +- cli/internal/tools/default-tools.json | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Assets/Tests/Editor/CliSetupApplicationServiceTests.cs b/Assets/Tests/Editor/CliSetupApplicationServiceTests.cs index 4deeec4afd..8db4d5e51b 100644 --- a/Assets/Tests/Editor/CliSetupApplicationServiceTests.cs +++ b/Assets/Tests/Editor/CliSetupApplicationServiceTests.cs @@ -38,7 +38,7 @@ public void GetMinimumRequiredCliVersion_RequiresDebugBreakCliRelease() new FakeCliInstallationDetector(new string[] { null }), new FakeNativeCliInstaller()); - Assert.That(service.GetMinimumRequiredCliVersion(), Is.EqualTo("3.0.0-beta.25")); + Assert.That(service.GetMinimumRequiredCliVersion(), Is.EqualTo("3.0.0-beta.26")); } [Test] @@ -49,7 +49,7 @@ public void GetMinimumRequiredCliReleaseTag_UsesCliGitHubReleaseTag() new FakeCliInstallationDetector(new string[] { null }), new FakeNativeCliInstaller()); - Assert.That(service.GetMinimumRequiredCliReleaseTag(), Is.EqualTo("cli-v3.0.0-beta.25")); + Assert.That(service.GetMinimumRequiredCliReleaseTag(), Is.EqualTo("cli-v3.0.0-beta.26")); } [Test] diff --git a/Packages/src/Editor/Domain/CliConstants.cs b/Packages/src/Editor/Domain/CliConstants.cs index 1e063fecbd..ff24e36468 100644 --- a/Packages/src/Editor/Domain/CliConstants.cs +++ b/Packages/src/Editor/Domain/CliConstants.cs @@ -6,7 +6,7 @@ namespace io.github.hatayama.UnityCliLoop.Domain public static class CliConstants { public const string EXECUTABLE_NAME = "uloop"; - public const string MINIMUM_REQUIRED_CLI_VERSION = "3.0.0-beta.25"; + public const string MINIMUM_REQUIRED_CLI_VERSION = "3.0.0-beta.26"; public const string MINIMUM_REQUIRED_CLI_RELEASE_TAG = CLI_RELEASE_TAG_PREFIX + MINIMUM_REQUIRED_CLI_VERSION; public const string VERSION_FLAG = "--version"; public const string SHORT_VERSION_FLAG = "-v"; diff --git a/cli/contract.json b/cli/contract.json index 06e57c6b20..f57f0693c2 100644 --- a/cli/contract.json +++ b/cli/contract.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "cliVersion": "3.0.0-beta.25" + "cliVersion": "3.0.0-beta.26" } diff --git a/cli/internal/tools/default-tools.json b/cli/internal/tools/default-tools.json index 735865b7c5..a725cf2ab6 100644 --- a/cli/internal/tools/default-tools.json +++ b/cli/internal/tools/default-tools.json @@ -1,5 +1,5 @@ { - "version": "3.0.0-beta.25", + "version": "3.0.0-beta.26", "tools": [ { "name": "compile",