From 7682c2d98e41be3bf570854c96145994f4c93eda Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 15 Jun 2026 00:32:42 +0900 Subject: [PATCH 1/3] Add pause point evidence summaries Group editor state, hit metadata, and matching log counts so E2E checks can distinguish clean single-hit evidence from stale, truncated, or repeated evidence. - Move pause point play state into EditorState with capture timing - Track hit timestamps and sequence numbers for pause point hits - Add EvidenceSummary to wait-for-pause-point results and bump the IPC protocol --- Assets/Tests/Editor/PausePointTests.cs | 25 +- .../CliOnlyTools~/PausePoint/Skill/SKILL.md | 8 +- Packages/src/Editor/Domain/CliConstants.cs | 2 +- .../PausePoint/PausePointTools.cs | 40 +++- .../Api/PausePointStatusBridgeCommand.cs | 39 ++- .../PausePoints/UloopPausePointRegistry.cs | 125 ++++++++-- cli/contract.json | 2 +- cli/internal/cli/pause_point_errors.go | 13 +- cli/internal/cli/pause_point_logs.go | 138 ++++++++++- cli/internal/cli/pause_point_wait.go | 48 ++-- cli/internal/cli/pause_point_wait_test.go | 222 +++++++++++++----- 11 files changed, 545 insertions(+), 117 deletions(-) diff --git a/Assets/Tests/Editor/PausePointTests.cs b/Assets/Tests/Editor/PausePointTests.cs index 5eb00bc7e3..ffce0e5373 100644 --- a/Assets/Tests/Editor/PausePointTests.cs +++ b/Assets/Tests/Editor/PausePointTests.cs @@ -68,10 +68,27 @@ public void Pause_WhenPausePointIsEnabled_RecordsHitAndRequestsPause() Assert.That(snapshot.Status, Is.EqualTo(UloopPausePointStatus.Hit)); Assert.That(snapshot.IsHit, Is.True); Assert.That(snapshot.IsEnabled, Is.False); - Assert.That(snapshot.IsPaused, Is.True); + Assert.That(snapshot.EditorState.IsPaused, Is.True); + Assert.That(snapshot.EditorState.CapturedAt, Is.EqualTo(UloopPausePointEditorStateCapturedAt.PausePointHit)); Assert.That(snapshot.HitCount, Is.EqualTo(1)); } + [Test] + public void Pause_WhenPausePointIsEnabled_RecordsHitEvidence() + { + // Verifies a hit snapshot includes stable timing and sequence evidence. + UloopPausePointRegistry.Enable("jump", 30); + _nowUtc = _nowUtc.AddMilliseconds(125); + + UloopPausePoint.Pause("jump"); + + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("jump"); + Assert.That(snapshot.FirstHitAtUtc, Is.EqualTo("2026-06-03T00:00:00.1250000Z")); + Assert.That(snapshot.LastHitAtUtc, Is.EqualTo("2026-06-03T00:00:00.1250000Z")); + Assert.That(snapshot.FirstHitSequence, Is.EqualTo(1)); + Assert.That(snapshot.LastHitSequence, Is.EqualTo(1)); + } + [Test] public void Pause_WhenPausePointIsEnabled_StoresLatestHitSnapshot() { @@ -162,6 +179,7 @@ public void GetStatus_WhenEnabled_ReportsTimingAndGenerationFields() Assert.That(snapshot.RemainingMilliseconds, Is.EqualTo(29750)); Assert.That(snapshot.Generation, Is.EqualTo(1)); Assert.That(snapshot.Expired, Is.False); + Assert.That(snapshot.EditorState.CapturedAt, Is.EqualTo(UloopPausePointEditorStateCapturedAt.Current)); } [Test] @@ -238,6 +256,9 @@ public async Task ClearAll_WhenNothingActive_ReportsNoActiveMessage() PausePointResponse response = (PausePointResponse)await tool.ExecuteAsync(parameters, CancellationToken.None); Assert.That(response.ClearedCount, Is.EqualTo(0)); + Assert.That(response.EditorState.IsPlaying, Is.True); + Assert.That(response.EditorState.IsPaused, Is.False); + Assert.That(response.EditorState.CapturedAt, Is.EqualTo(UloopPausePointEditorStateCapturedAt.ClearAll)); Assert.That(response.Message, Is.EqualTo("No active pause points to clear.")); } @@ -251,6 +272,7 @@ public async Task Enable_WhenMarkerCreated_ReturnsStateManagementFields() Assert.That(response.RemainingMilliseconds, Is.EqualTo(30000)); Assert.That(response.Generation, Is.EqualTo(1)); Assert.That(response.Expired, Is.False); + Assert.That(response.EditorState.CapturedAt, Is.EqualTo(UloopPausePointEditorStateCapturedAt.Current)); Assert.That(response.RecommendedNextAction, Is.Empty); } @@ -271,6 +293,7 @@ public void PausePointStatusBridge_WhenMarkerExpired_ReturnsRecoveryAction() Assert.That(response.EnabledAtUtc, Is.EqualTo("2026-06-03T00:00:00.0000000Z")); Assert.That(response.RemainingMilliseconds, Is.EqualTo(0)); Assert.That(response.Generation, Is.EqualTo(1)); + Assert.That(response.EditorState.CapturedAt, Is.EqualTo(UloopPausePointEditorStateCapturedAt.Current)); Assert.That( response.RecommendedNextAction, Is.EqualTo("Clear this marker, then re-enable it with the same Id and TimeoutSeconds values.")); diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md index 5b3baa2fd1..794950b40a 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -32,10 +32,12 @@ uloop wait-for-pause-point --id state-transition-applied --timeout-seconds 30 The hit response always embeds the log entries matching the marker id as `MatchingLogs` (`--matching-logs-max-count` adjusts the limit, default 10), so a separate `get-logs` call while paused is unnecessary. Log embedding is always on; there is no opt-in flag, and a `--include-matching-logs` option no longer exists. An empty `MatchingLogs` array means the fetch succeeded and no matching log exists; if the field is absent, the log fetch itself failed, so fall back to `uloop get-logs --search-text state-transition-applied --max-count 20` while paused. -Use `Generation` and `EnabledAtUtc` from the hit or status response to tell a fresh marker from stale evidence with the same id. `RemainingMilliseconds` and `Expired` are returned directly so you do not need to infer marker lifetime from elapsed time. +Read `EvidenceSummary` first when it is present. It groups `EditorState`, pause point hit metadata, matching-log counts, truncation status, and warnings so you can tell whether the evidence is a single clean hit or needs closer inspection. `EditorState.CapturedAt` names when the Unity Editor play/pause state was observed, such as `PausePointHit`, `Current`, or `ClearAll`. + +Use `Generation`, `EnabledAtUtc`, and the hit sequence fields from the hit or status response to tell a fresh marker from stale evidence with the same id. `RemainingMilliseconds` and `Expired` are returned directly so you do not need to infer marker lifetime from elapsed time. 5. While Unity is still paused, capture any additional evidence with `uloop execute-dynamic-code`, `uloop get-hierarchy`, `uloop find-game-objects`, and one screenshot. -6. Clear the marker with `uloop clear-pause-point --id state-transition-applied` or stop PlayMode before moving on. Use `uloop clear-pause-point --all` to clear every active marker at once, for example when resetting between E2E scenarios. +6. Clear the marker with `uloop clear-pause-point --id state-transition-applied` or stop PlayMode before moving on. Use `uloop clear-pause-point --all` to clear every active marker at once, for example when resetting between E2E scenarios. The clear response's `EditorState` describes Unity Editor play/pause state, not marker state. ## When To Use @@ -50,7 +52,7 @@ Use `Generation` and `EnabledAtUtc` from the hit or status response to tell a fr ## Timeout Checks -If this command times out, the marker line was not reached while the command waited. Read `error.details.hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. A `PAUSE_POINT_EXPIRED` error carries the same hint and shell-neutral `error.details.recommendedNextAction`: it means the marker's own `enable-pause-point --timeout-seconds` window (measured from enable, not from wait) ran out first, so clear and re-enable the marker using the returned `id` and `timeoutSeconds`. Then inspect `error.details.status`, `hitCount`, `generation`, `enabledAtUtc`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, stale evidence from an older marker generation, runtime conditions not being met, an id mismatch, or Unity already being paused. `error.details.MatchingLogs` shows whether the marker's focused log ever appeared. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. +If this command times out, the marker line was not reached while the command waited. Read `error.details.hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. A `PAUSE_POINT_EXPIRED` error carries the same hint and shell-neutral `error.details.recommendedNextAction`: it means the marker's own `enable-pause-point --timeout-seconds` window (measured from enable, not from wait) ran out first, so clear and re-enable the marker using the returned `id` and `timeoutSeconds`. Then inspect `error.details.status`, `hitCount`, `generation`, `enabledAtUtc`, `editorState`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, stale evidence from an older marker generation, runtime conditions not being met, an id mismatch, or Unity already being paused. `error.details.MatchingLogs` and `error.details.EvidenceSummary` show whether the marker's focused log ever appeared. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. Use `uloop pause-point-status --id state-transition-applied` only when you need to confirm the marker is armed or inspect the current hit state. Add focused debug logs before the marker when local variables must be captured. diff --git a/Packages/src/Editor/Domain/CliConstants.cs b/Packages/src/Editor/Domain/CliConstants.cs index 00f2a73f9b..7ca5f8e881 100644 --- a/Packages/src/Editor/Domain/CliConstants.cs +++ b/Packages/src/Editor/Domain/CliConstants.cs @@ -9,7 +9,7 @@ public static class CliConstants // Why: the runtime IPC gate compares this contract generation, not release numbers. // Bump it together with cli/contract.json protocolVersion only when this package can // no longer interoperate with a different CLI protocol generation. - public const int REQUIRED_CLI_PROTOCOL_VERSION = 1; + public const int REQUIRED_CLI_PROTOCOL_VERSION = 2; // Why: setup installs this pinned release; it must always point at a published CLI // release, so it advances after releases, never inside feature PRs. public const string MINIMUM_REQUIRED_CLI_VERSION = "3.0.0-beta.32"; diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs index 19a894e1b1..850357b4e6 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs @@ -44,8 +44,11 @@ public class PausePointResponse : UnityCliLoopToolResponse public long ElapsedSinceEnabledMilliseconds { get; set; } public long RemainingMilliseconds { get; set; } public int Generation { get; set; } - public bool IsPlaying { get; set; } - public bool IsPaused { get; set; } + public PausePointEditorState EditorState { get; set; } = new(); + public string FirstHitAtUtc { get; set; } = string.Empty; + public string LastHitAtUtc { get; set; } = string.Empty; + public int FirstHitSequence { get; set; } + public int LastHitSequence { get; set; } public int ClearedCount { get; set; } public string Message { get; set; } = string.Empty; public string RecommendedNextAction { get; set; } = string.Empty; @@ -71,8 +74,11 @@ internal static PausePointResponse FromSnapshot(UloopPausePointSnapshot snapshot ElapsedSinceEnabledMilliseconds = snapshot.ElapsedSinceEnabledMilliseconds, RemainingMilliseconds = snapshot.RemainingMilliseconds, Generation = snapshot.Generation, - IsPlaying = snapshot.IsPlaying, - IsPaused = snapshot.IsPaused, + EditorState = PausePointEditorState.FromSnapshot(snapshot.EditorState), + FirstHitAtUtc = snapshot.FirstHitAtUtc, + LastHitAtUtc = snapshot.LastHitAtUtc, + FirstHitSequence = snapshot.FirstHitSequence, + LastHitSequence = snapshot.LastHitSequence, Message = snapshot.Message, RecommendedNextAction = snapshot.RecommendedNextAction }; @@ -89,6 +95,7 @@ internal static PausePointResponse FromClearAll(UloopPausePointClearAllResult re { Status = UloopPausePointStatus.Cleared, ClearedCount = result.ClearedCount, + EditorState = PausePointEditorState.FromSnapshot(result.EditorState), Message = result.ClearedCount == 0 ? "No active pause points to clear." : "Pause points cleared." @@ -96,6 +103,31 @@ internal static PausePointResponse FromClearAll(UloopPausePointClearAllResult re } } + /// + /// Unity Editor play state attached to pause point tool evidence. + /// + public class PausePointEditorState + { + public bool IsPlaying { get; set; } + public bool IsPaused { get; set; } + public string CapturedAt { get; set; } = string.Empty; + + internal static PausePointEditorState FromSnapshot(UloopPausePointEditorStateSnapshot snapshot) + { + if (snapshot == null) + { + throw new ArgumentNullException(nameof(snapshot)); + } + + return new PausePointEditorState + { + IsPlaying = snapshot.IsPlaying, + IsPaused = snapshot.IsPaused, + CapturedAt = snapshot.CapturedAt + }; + } + } + /// /// Exposes pause point enabling as a Unity CLI Loop tool. /// diff --git a/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs b/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs index cd88855c79..c5e3bba70f 100644 --- a/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs +++ b/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs @@ -55,8 +55,11 @@ public class PausePointStatusResponse : UnityCliLoopToolResponse public long ElapsedSinceEnabledMilliseconds { get; set; } public long RemainingMilliseconds { get; set; } public int Generation { get; set; } - public bool IsPlaying { get; set; } - public bool IsPaused { get; set; } + public PausePointStatusEditorState EditorState { get; set; } = new(); + public string FirstHitAtUtc { get; set; } = string.Empty; + public string LastHitAtUtc { get; set; } = string.Empty; + public int FirstHitSequence { get; set; } + public int LastHitSequence { get; set; } public string Message { get; set; } = string.Empty; public string RecommendedNextAction { get; set; } = string.Empty; @@ -80,11 +83,39 @@ internal static PausePointStatusResponse FromSnapshot(UloopPausePointSnapshot sn ElapsedSinceEnabledMilliseconds = snapshot.ElapsedSinceEnabledMilliseconds, RemainingMilliseconds = snapshot.RemainingMilliseconds, Generation = snapshot.Generation, - IsPlaying = snapshot.IsPlaying, - IsPaused = snapshot.IsPaused, + EditorState = PausePointStatusEditorState.FromSnapshot(snapshot.EditorState), + FirstHitAtUtc = snapshot.FirstHitAtUtc, + LastHitAtUtc = snapshot.LastHitAtUtc, + FirstHitSequence = snapshot.FirstHitSequence, + LastHitSequence = snapshot.LastHitSequence, Message = snapshot.Message, RecommendedNextAction = snapshot.RecommendedNextAction }; } } + + /// + /// Unity Editor play state attached to pause point status evidence. + /// + public class PausePointStatusEditorState + { + public bool IsPlaying { get; set; } + public bool IsPaused { get; set; } + public string CapturedAt { get; set; } = string.Empty; + + internal static PausePointStatusEditorState FromSnapshot(UloopPausePointEditorStateSnapshot snapshot) + { + if (snapshot == null) + { + throw new ArgumentNullException(nameof(snapshot)); + } + + return new PausePointStatusEditorState + { + IsPlaying = snapshot.IsPlaying, + IsPaused = snapshot.IsPaused, + CapturedAt = snapshot.CapturedAt + }; + } + } } diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs index c955a85b85..e483a9b015 100644 --- a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs +++ b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs @@ -17,6 +17,7 @@ internal static class UloopPausePointRegistry private static IUloopPausePointPauseController _pauseController = new UnityEditorPausePointPauseController(); private static Func _nowProvider = () => DateTime.UtcNow; private static int _nextGeneration; + private static int _nextHitSequence; private static UloopPausePointSnapshot _latestHitSnapshot; // One input can hit several markers in the same frame; tools need the full list, // not just the latest hit, to report every marker that interrupted them. @@ -76,7 +77,10 @@ public static UloopPausePointClearAllResult ClearAll() } ClearLatestHitSnapshot(); - return new UloopPausePointClearAllResult(clearedCount, now); + UloopPausePointEditorStateSnapshot editorState = UloopPausePointEditorStateSnapshot.FromController( + _pauseController, + UloopPausePointEditorStateCapturedAt.ClearAll); + return new UloopPausePointClearAllResult(clearedCount, now, editorState); } public static UloopPausePointSnapshot GetStatus(string id) @@ -116,7 +120,8 @@ public static UloopPausePointSnapshot Hit(string id) } _pauseController.Pause(); - entry.RecordHit(now, _pauseController.IsPlaying, _pauseController.IsPaused); + int hitSequence = ++_nextHitSequence; + entry.RecordHit(now, _pauseController.IsPlaying, _pauseController.IsPaused, hitSequence); UloopPausePointSnapshot snapshot = entry.ToSnapshot(now, _pauseController); _latestHitSnapshot = snapshot; _hitSnapshots.RemoveAll(hitSnapshot => hitSnapshot.Id == id); @@ -169,6 +174,7 @@ public static void ResetForTests() { Entries.Clear(); _nextGeneration = 0; + _nextHitSequence = 0; _latestHitSnapshot = null; _hitSnapshots.Clear(); _pauseController = new UnityEditorPausePointPauseController(); @@ -182,6 +188,47 @@ private static DateTime NowUtc() } } + /// + /// Names when a pause point editor-state snapshot was captured. + /// + internal static class UloopPausePointEditorStateCapturedAt + { + public const string Current = "Current"; + public const string PausePointHit = "PausePointHit"; + public const string ClearAll = "ClearAll"; + } + + /// + /// Immutable Unity Editor state attached to pause point evidence. + /// + internal sealed class UloopPausePointEditorStateSnapshot + { + public UloopPausePointEditorStateSnapshot(bool isPlaying, bool isPaused, string capturedAt) + { + Debug.Assert(!string.IsNullOrWhiteSpace(capturedAt), "capturedAt must not be null or empty"); + + IsPlaying = isPlaying; + IsPaused = isPaused; + CapturedAt = capturedAt ?? string.Empty; + } + + public bool IsPlaying { get; } + public bool IsPaused { get; } + public string CapturedAt { get; } + + public static UloopPausePointEditorStateSnapshot FromController( + IUloopPausePointPauseController pauseController, + string capturedAt) + { + Debug.Assert(pauseController != null, "pauseController must not be null"); + + return new UloopPausePointEditorStateSnapshot( + pauseController.IsPlaying, + pauseController.IsPaused, + capturedAt); + } + } + /// /// Provides the current pause state and performs the actual Unity Editor pause request. /// @@ -223,11 +270,16 @@ public UloopPausePointSnapshot( long elapsedMilliseconds, long remainingMilliseconds, int generation, - bool isPlaying, - bool isPaused, + UloopPausePointEditorStateSnapshot editorState, + string firstHitAtUtc, + string lastHitAtUtc, + int firstHitSequence, + int lastHitSequence, string message, string recommendedNextAction) { + Debug.Assert(editorState != null, "editorState must not be null"); + Id = id ?? string.Empty; Status = status ?? UloopPausePointStatus.NotEnabled; IsEnabled = isEnabled; @@ -239,8 +291,11 @@ public UloopPausePointSnapshot( ElapsedSinceEnabledMilliseconds = elapsedMilliseconds; RemainingMilliseconds = remainingMilliseconds; Generation = generation; - IsPlaying = isPlaying; - IsPaused = isPaused; + EditorState = editorState; + FirstHitAtUtc = firstHitAtUtc ?? string.Empty; + LastHitAtUtc = lastHitAtUtc ?? string.Empty; + FirstHitSequence = firstHitSequence; + LastHitSequence = lastHitSequence; Message = message ?? string.Empty; RecommendedNextAction = recommendedNextAction ?? string.Empty; } @@ -256,8 +311,11 @@ public UloopPausePointSnapshot( public long ElapsedSinceEnabledMilliseconds { get; } public long RemainingMilliseconds { get; } public int Generation { get; } - public bool IsPlaying { get; } - public bool IsPaused { get; } + public UloopPausePointEditorStateSnapshot EditorState { get; } + public string FirstHitAtUtc { get; } + public string LastHitAtUtc { get; } + public int FirstHitSequence { get; } + public int LastHitSequence { get; } public string Message { get; } public string RecommendedNextAction { get; } @@ -277,8 +335,13 @@ public static UloopPausePointSnapshot NotEnabled(string id, IUloopPausePointPaus 0, 0, 0, - pauseController.IsPlaying, - pauseController.IsPaused, + UloopPausePointEditorStateSnapshot.FromController( + pauseController, + UloopPausePointEditorStateCapturedAt.Current), + string.Empty, + string.Empty, + 0, + 0, "Pause point is not enabled.", string.Empty); } @@ -289,14 +352,21 @@ public static UloopPausePointSnapshot NotEnabled(string id, IUloopPausePointPaus /// internal sealed class UloopPausePointClearAllResult { - public UloopPausePointClearAllResult(int clearedCount, DateTime clearedAtUtc) + public UloopPausePointClearAllResult( + int clearedCount, + DateTime clearedAtUtc, + UloopPausePointEditorStateSnapshot editorState) { + Debug.Assert(editorState != null, "editorState must not be null"); + ClearedCount = clearedCount; ClearedAtUtc = clearedAtUtc; + EditorState = editorState; } public int ClearedCount { get; } public DateTime ClearedAtUtc { get; } + public UloopPausePointEditorStateSnapshot EditorState { get; } } /// @@ -324,7 +394,10 @@ public UloopPausePointEntry(string id, int timeoutSeconds, DateTime enabledAtUtc public string Status { get; private set; } public bool IsEnabled { get; private set; } public int HitCount { get; private set; } + public DateTime FirstHitAtUtc { get; private set; } public DateTime HitAtUtc { get; private set; } + public int FirstHitSequence { get; private set; } + public int LastHitSequence { get; private set; } public bool IsPlayingAtHit { get; private set; } public bool IsPausedAtHit { get; private set; } public string Message { get; private set; } @@ -353,10 +426,19 @@ public void MarkCleared(string message = "Pause point cleared.") Message = message; } - public void RecordHit(DateTime nowUtc, bool isPlaying, bool isPaused) + public void RecordHit(DateTime nowUtc, bool isPlaying, bool isPaused, int hitSequence) { + Debug.Assert(hitSequence > 0, "hitSequence must be greater than zero"); + + if (HitCount == 0) + { + FirstHitAtUtc = nowUtc; + FirstHitSequence = hitSequence; + } + HitCount++; HitAtUtc = nowUtc; + LastHitSequence = hitSequence; IsPlayingAtHit = isPlaying; IsPausedAtHit = isPaused; IsEnabled = false; @@ -370,11 +452,19 @@ public UloopPausePointSnapshot ToSnapshot(DateTime nowUtc, IUloopPausePointPause bool isHit = Status == UloopPausePointStatus.Hit; bool expired = Status == UloopPausePointStatus.Expired; - bool isPlaying = isHit ? IsPlayingAtHit : pauseController.IsPlaying; - bool isPaused = isHit ? IsPausedAtHit : pauseController.IsPaused; + UloopPausePointEditorStateSnapshot editorState = isHit + ? new UloopPausePointEditorStateSnapshot( + IsPlayingAtHit, + IsPausedAtHit, + UloopPausePointEditorStateCapturedAt.PausePointHit) + : UloopPausePointEditorStateSnapshot.FromController( + pauseController, + UloopPausePointEditorStateCapturedAt.Current); long elapsedMilliseconds = Math.Max(0, (long)(nowUtc - EnabledAtUtc).TotalMilliseconds); long remainingMilliseconds = CalculateRemainingMilliseconds(nowUtc); string recommendedNextAction = expired ? CreateExpiredRecommendedNextAction() : string.Empty; + string firstHitAtUtc = HitCount > 0 ? FormatUtc(FirstHitAtUtc) : string.Empty; + string lastHitAtUtc = HitCount > 0 ? FormatUtc(HitAtUtc) : string.Empty; return new UloopPausePointSnapshot( Id, @@ -388,8 +478,11 @@ public UloopPausePointSnapshot ToSnapshot(DateTime nowUtc, IUloopPausePointPause elapsedMilliseconds, remainingMilliseconds, Generation, - isPlaying, - isPaused, + editorState, + firstHitAtUtc, + lastHitAtUtc, + FirstHitSequence, + LastHitSequence, Message, recommendedNextAction); } diff --git a/cli/contract.json b/cli/contract.json index fc6fe282b1..06a39a8448 100644 --- a/cli/contract.json +++ b/cli/contract.json @@ -1,5 +1,5 @@ { "schemaVersion": 1, - "protocolVersion": 1, + "protocolVersion": 2, "cliVersion": "3.0.0-beta.32" } diff --git a/cli/internal/cli/pause_point_errors.go b/cli/internal/cli/pause_point_errors.go index bdf6f6c21f..42769bde25 100644 --- a/cli/internal/cli/pause_point_errors.go +++ b/cli/internal/cli/pause_point_errors.go @@ -67,10 +67,10 @@ const ( // pausePointTimeoutHint maps the final probed status to a deterministic diagnosis, // because timeouts are where agents struggle to tell a missed code path from Editor state. func pausePointTimeoutHint(response pausePointStatusResponse) string { - if !response.IsPlaying { + if !response.EditorState.IsPlaying { return pausePointHintPlayModeNotRunning } - if response.IsPaused { + if response.EditorState.IsPaused { return pausePointHintEditorAlreadyPaused } if response.HitCount == 0 && response.Status == pausePointStatusEnabled { @@ -83,10 +83,10 @@ func pausePointTimeoutHint(response pausePointStatusResponse) string { // whose enable window ends before the wait deadline surfaces as PAUSE_POINT_EXPIRED instead // of a timeout and would otherwise carry no hint at all. func pausePointExpiredHint(response pausePointStatusResponse) string { - if !response.IsPlaying { + if !response.EditorState.IsPlaying { return pausePointHintPlayModeNotRunning } - if response.IsPaused { + if response.EditorState.IsPaused { return pausePointHintEditorAlreadyPaused } if response.HitCount == 0 { @@ -114,7 +114,7 @@ func pausePointStateError( NextActions: []string{ "Run `uloop enable-pause-point --id ` before waiting.", "Confirm the code path calls `UloopPausePoint.Pause(\"\")` with the same id.", - "Check `details.status`, `details.isPlaying`, `details.isPaused`, `details.elapsedSinceEnabledMilliseconds`, and `details.remainingMilliseconds` to distinguish a missed code path from an already-paused Editor.", + "Check `details.status`, `details.editorState`, `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{ @@ -126,8 +126,7 @@ func pausePointStateError( "enabledAtUtc": response.EnabledAtUtc, "elapsedSinceEnabledMilliseconds": response.ElapsedSinceEnabledMilliseconds, "generation": response.Generation, - "isPlaying": response.IsPlaying, - "isPaused": response.IsPaused, + "editorState": response.EditorState, "remainingMilliseconds": pausePointRemainingMilliseconds(options, response), "markerMessage": response.Message, "recommendedNextAction": response.RecommendedNextAction, diff --git a/cli/internal/cli/pause_point_logs.go b/cli/internal/cli/pause_point_logs.go index c9ec24bc71..f43adba0f3 100644 --- a/cli/internal/cli/pause_point_logs.go +++ b/cli/internal/cli/pause_point_logs.go @@ -3,6 +3,7 @@ package cli import ( "context" "encoding/json" + "strings" "github.com/hatayama/unity-cli-loop/cli/internal/unityipc" ) @@ -21,15 +22,56 @@ type pausePointMatchingLog struct { Message string `json:"Message"` } -// pausePointWaitResult extends the hit response with marker-matching logs so -// agents do not need a separate get-logs call while Unity is paused. +type pausePointMatchingLogsResult struct { + SearchText string `json:"SearchText"` + TotalCount int `json:"TotalCount"` + DisplayedCount int `json:"DisplayedCount"` + MaxCount int `json:"MaxCount"` + Logs []pausePointMatchingLog `json:"Logs"` +} + +type pausePointEvidenceSummary struct { + EditorState pausePointEditorState `json:"EditorState"` + PausePoint pausePointEvidencePausePoint `json:"PausePoint"` + MatchingLogs pausePointEvidenceLogs `json:"MatchingLogs"` + Warning string `json:"Warning"` +} + +type pausePointEvidencePausePoint struct { + Id string `json:"Id"` + Status string `json:"Status"` + Generation int `json:"Generation"` + HitCount int `json:"HitCount"` + MultipleHitsObserved bool `json:"MultipleHitsObserved"` + FirstHitAtUtc string `json:"FirstHitAtUtc"` + LastHitAtUtc string `json:"LastHitAtUtc"` + FirstHitSequence int `json:"FirstHitSequence"` + LastHitSequence int `json:"LastHitSequence"` +} + +type pausePointEvidenceLogs struct { + SearchText string `json:"SearchText"` + MatchingLogCount int `json:"MatchingLogCount"` + ReturnedLogCount int `json:"ReturnedLogCount"` + MaxCount int `json:"MaxCount"` + MayBeTruncated bool `json:"MayBeTruncated"` + MultipleMatchingLogsObserved bool `json:"MultipleMatchingLogsObserved"` +} + +// pausePointWaitResult extends the hit response with marker-matching logs and +// evidence summary so agents do not need a separate get-logs call while Unity is paused. type pausePointWaitResult struct { pausePointStatusResponse - MatchingLogs []pausePointMatchingLog `json:"MatchingLogs"` + MatchingLogs []pausePointMatchingLog `json:"MatchingLogs"` + EvidenceSummary pausePointEvidenceSummary `json:"EvidenceSummary"` } type pausePointGetLogsResponse struct { - Logs []pausePointMatchingLog `json:"Logs"` + TotalCount int `json:"TotalCount"` + DisplayedCount int `json:"DisplayedCount"` + MaxCount int `json:"MaxCount"` + SearchText string `json:"SearchText"` + Logs []pausePointMatchingLog `json:"Logs"` } func fetchMatchingLogsFromUnity( @@ -37,7 +79,7 @@ func fetchMatchingLogsFromUnity( connection unityipc.Connection, searchText string, maxCount int, -) ([]pausePointMatchingLog, error) { +) (pausePointMatchingLogsResult, error) { probeContext, cancel := context.WithTimeout(ctx, pausePointStatusProbeTimeout) defer cancel() @@ -50,15 +92,93 @@ func fetchMatchingLogsFromUnity( }, ) if err != nil { - return nil, err + return pausePointMatchingLogsResult{}, err } response := pausePointGetLogsResponse{} if err := json.Unmarshal(result, &response); err != nil { - return nil, err + return pausePointMatchingLogsResult{}, err } if response.Logs == nil { - return []pausePointMatchingLog{}, nil + response.Logs = []pausePointMatchingLog{} + } + if response.SearchText == "" { + response.SearchText = searchText + } + if response.MaxCount == 0 { + response.MaxCount = maxCount + } + if response.DisplayedCount == 0 && len(response.Logs) > 0 { + response.DisplayedCount = len(response.Logs) + } + if response.TotalCount < len(response.Logs) { + response.TotalCount = len(response.Logs) + } + + return pausePointMatchingLogsResult{ + SearchText: response.SearchText, + TotalCount: response.TotalCount, + DisplayedCount: response.DisplayedCount, + MaxCount: response.MaxCount, + Logs: response.Logs, + }, nil +} + +func buildPausePointEvidenceSummary( + response pausePointStatusResponse, + logs pausePointMatchingLogsResult, +) pausePointEvidenceSummary { + return pausePointEvidenceSummary{ + EditorState: response.EditorState, + PausePoint: pausePointEvidencePausePoint{ + Id: response.Id, + Status: response.Status, + Generation: response.Generation, + HitCount: response.HitCount, + MultipleHitsObserved: response.HitCount > 1, + FirstHitAtUtc: response.FirstHitAtUtc, + LastHitAtUtc: response.LastHitAtUtc, + FirstHitSequence: response.FirstHitSequence, + LastHitSequence: response.LastHitSequence, + }, + MatchingLogs: buildPausePointEvidenceLogs(logs), + Warning: buildPausePointEvidenceWarning(logs, response.HitCount), + } +} + +func buildPausePointEvidenceLogs(logs pausePointMatchingLogsResult) pausePointEvidenceLogs { + matchingLogCount := logs.TotalCount + if matchingLogCount < len(logs.Logs) { + matchingLogCount = len(logs.Logs) + } + returnedLogCount := len(logs.Logs) + return pausePointEvidenceLogs{ + SearchText: logs.SearchText, + MatchingLogCount: matchingLogCount, + ReturnedLogCount: returnedLogCount, + MaxCount: logs.MaxCount, + MayBeTruncated: matchingLogCount > returnedLogCount, + MultipleMatchingLogsObserved: matchingLogCount > 1, + } +} + +func buildPausePointEvidenceWarning(logs pausePointMatchingLogsResult, hitCount int) string { + evidenceLogs := buildPausePointEvidenceLogs(logs) + warnings := []string{} + if evidenceLogs.MayBeTruncated { + warnings = append( + warnings, + "Matching logs may be truncated by --matching-logs-max-count; increase the limit before treating this as complete evidence.") + } + if evidenceLogs.MultipleMatchingLogsObserved { + warnings = append( + warnings, + "Multiple matching logs were returned for this pause point id; inspect MatchingLogs before treating the scenario as single-fire evidence.") + } + if hitCount > 1 { + warnings = append( + warnings, + "The pause point reports multiple hits; inspect the paused state before treating the scenario as single-fire evidence.") } - return response.Logs, nil + return strings.Join(warnings, " ") } diff --git a/cli/internal/cli/pause_point_wait.go b/cli/internal/cli/pause_point_wait.go index 97b6775cf9..487cffad5a 100644 --- a/cli/internal/cli/pause_point_wait.go +++ b/cli/internal/cli/pause_point_wait.go @@ -46,21 +46,30 @@ type pausePointStatusOptions struct { } type pausePointStatusResponse 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"` - Expired bool `json:"Expired"` - EnabledAtUtc string `json:"EnabledAtUtc"` - ElapsedSinceEnabledMilliseconds int64 `json:"ElapsedSinceEnabledMilliseconds"` - RemainingMilliseconds int64 `json:"RemainingMilliseconds"` - Generation int `json:"Generation"` - IsPlaying bool `json:"IsPlaying"` - IsPaused bool `json:"IsPaused"` - Message string `json:"Message"` - RecommendedNextAction string `json:"RecommendedNextAction"` + Id string `json:"Id"` + Status string `json:"Status"` + IsEnabled bool `json:"IsEnabled"` + IsHit bool `json:"IsHit"` + HitCount int `json:"HitCount"` + TimeoutSeconds int `json:"TimeoutSeconds"` + Expired bool `json:"Expired"` + EnabledAtUtc string `json:"EnabledAtUtc"` + ElapsedSinceEnabledMilliseconds int64 `json:"ElapsedSinceEnabledMilliseconds"` + RemainingMilliseconds int64 `json:"RemainingMilliseconds"` + Generation int `json:"Generation"` + EditorState pausePointEditorState `json:"EditorState"` + FirstHitAtUtc string `json:"FirstHitAtUtc"` + LastHitAtUtc string `json:"LastHitAtUtc"` + FirstHitSequence int `json:"FirstHitSequence"` + LastHitSequence int `json:"LastHitSequence"` + Message string `json:"Message"` + RecommendedNextAction string `json:"RecommendedNextAction"` +} + +type pausePointEditorState struct { + IsPlaying bool `json:"IsPlaying"` + IsPaused bool `json:"IsPaused"` + CapturedAt string `json:"CapturedAt"` } type pausePointWaitState string @@ -182,7 +191,11 @@ func runWaitForPausePoint( var payload any = response logs, logsErr := fetchMatchingLogs(ctx, connection, options.id, options.matchingLogsMaxCount) if logsErr == nil { - payload = pausePointWaitResult{pausePointStatusResponse: response, MatchingLogs: logs} + payload = pausePointWaitResult{ + pausePointStatusResponse: response, + MatchingLogs: logs.Logs, + EvidenceSummary: buildPausePointEvidenceSummary(response, logs), + } } result, marshalErr := json.Marshal(payload) if marshalErr != nil { @@ -206,7 +219,8 @@ func runWaitForPausePoint( // Best-effort: the timeout diagnosis must not depend on a second Unity round trip succeeding. logs, logsErr := fetchMatchingLogs(ctx, connection, options.id, options.matchingLogsMaxCount) if logsErr == nil { - waitErr.Details["MatchingLogs"] = logs + waitErr.Details["MatchingLogs"] = logs.Logs + waitErr.Details["EvidenceSummary"] = buildPausePointEvidenceSummary(response, logs) } } writeErrorEnvelope(stderr, waitErr) diff --git a/cli/internal/cli/pause_point_wait_test.go b/cli/internal/cli/pause_point_wait_test.go index 9c59fc5c54..c520d4c74e 100644 --- a/cli/internal/cli/pause_point_wait_test.go +++ b/cli/internal/cli/pause_point_wait_test.go @@ -24,7 +24,13 @@ func TestWaitForPausePointReturnsHitAfterEnabledStatus(t *testing.T) { responses := []pausePointStatusResponse{ {Id: "jump", Status: pausePointStatusEnabled, IsEnabled: true}, - {Id: "jump", Status: pausePointStatusHit, IsHit: true, IsPaused: true, HitCount: 1}, + { + Id: "jump", + Status: pausePointStatusHit, + IsHit: true, + HitCount: 1, + EditorState: pausePointEditorState{IsPlaying: true, IsPaused: true, CapturedAt: "PausePointHit"}, + }, } requestCount := 0 queryPausePointStatus = func( @@ -82,8 +88,7 @@ func TestRunWaitForPausePointClearsEnabledMarkerAfterTimeout(t *testing.T) { IsEnabled: true, TimeoutSeconds: 1, ElapsedSinceEnabledMilliseconds: 100, - IsPlaying: true, - IsPaused: false, + EditorState: pausePointEditorState{IsPlaying: true, CapturedAt: "Current"}, Message: "Pause point enabled.", }, nil } @@ -116,11 +121,9 @@ func TestRunWaitForPausePointClearsEnabledMarkerAfterTimeout(t *testing.T) { t.Fatalf("timeout error missing from stderr: %s", stderr.String()) } envelope := parsePausePointErrorEnvelope(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) + editorState, ok := envelope.Error.Details["editorState"].(map[string]any) + if !ok || editorState["IsPlaying"] != true || editorState["IsPaused"] != false || editorState["CapturedAt"] != "Current" { + t.Fatalf("editorState detail mismatch: %#v", envelope.Error.Details) } if envelope.Error.Details["markerMessage"] != "Pause point enabled." { t.Fatalf("markerMessage detail mismatch: %#v", envelope.Error.Details) @@ -144,7 +147,7 @@ func TestPausePointExpiredErrorReportsRecoveryFields(t *testing.T) { ElapsedSinceEnabledMilliseconds: 1200, RemainingMilliseconds: 0, Generation: 7, - IsPlaying: true, + EditorState: pausePointEditorState{IsPlaying: true, CapturedAt: "Current"}, Message: "Pause point expired before it was hit.", RecommendedNextAction: "Clear this marker, then re-enable it with the same Id and TimeoutSeconds values.", } @@ -174,7 +177,7 @@ func TestPausePointExpiredErrorReportsMarkerTimeoutSeconds(t *testing.T) { Id: "jump", Status: pausePointStatusExpired, TimeoutSeconds: 30, - IsPlaying: true, + EditorState: pausePointEditorState{IsPlaying: true, CapturedAt: "Current"}, Message: "Pause point expired before it was hit.", } @@ -191,10 +194,10 @@ func TestPausePointExpiredErrorReportsMarkerTimeoutSeconds(t *testing.T) { // Verifies wait errors derive Expired from Status when older Unity packages omit the bool field. func TestPausePointExpiredErrorDerivesExpiredFromStatus(t *testing.T) { response := pausePointStatusResponse{ - Id: "jump", - Status: pausePointStatusExpired, - IsPlaying: true, - Message: "Pause point expired before it was hit.", + Id: "jump", + Status: pausePointStatusExpired, + EditorState: pausePointEditorState{IsPlaying: true, CapturedAt: "Current"}, + Message: "Pause point expired before it was hit.", } cliErr := pausePointWaitError("/tmp/MyProject", waitForPausePointOptions{ @@ -231,11 +234,11 @@ func TestRunWaitForPausePointReturnsFinalHitAtTimeoutBoundary(t *testing.T) { }, nil } return pausePointStatusResponse{ - Id: id, - Status: pausePointStatusHit, - IsHit: true, - IsPaused: true, - HitCount: 1, + Id: id, + Status: pausePointStatusHit, + IsHit: true, + HitCount: 1, + EditorState: pausePointEditorState{IsPlaying: true, IsPaused: true, CapturedAt: "PausePointHit"}, }, nil } @@ -287,7 +290,11 @@ func TestWaitForPausePointReturnsNotEnabledStateImmediately(t *testing.T) { connection unityipc.Connection, id string, ) (pausePointStatusResponse, error) { - return pausePointStatusResponse{Id: id, Status: pausePointStatusNotEnabled, IsPlaying: true}, nil + return pausePointStatusResponse{ + Id: id, + Status: pausePointStatusNotEnabled, + EditorState: pausePointEditorState{IsPlaying: true, CapturedAt: "Current"}, + }, nil } response, state, err := waitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ @@ -319,11 +326,10 @@ func TestRunWaitForPausePointReportsNotEnabledError(t *testing.T) { id string, ) (pausePointStatusResponse, error) { return pausePointStatusResponse{ - Id: id, - Status: pausePointStatusNotEnabled, - IsPlaying: true, - IsPaused: false, - Message: "Pause point is not enabled.", + Id: id, + Status: pausePointStatusNotEnabled, + EditorState: pausePointEditorState{IsPlaying: true, CapturedAt: "Current"}, + Message: "Pause point is not enabled.", }, nil } @@ -345,8 +351,9 @@ func TestRunWaitForPausePointReportsNotEnabledError(t *testing.T) { if envelope.Error.Details["status"] != pausePointStatusNotEnabled { 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) + editorState, ok := envelope.Error.Details["editorState"].(map[string]any) + if !ok || editorState["IsPlaying"] != true || editorState["IsPaused"] != false || editorState["CapturedAt"] != "Current" { + t.Fatalf("editorState details mismatch: %#v", envelope.Error.Details) } } @@ -394,7 +401,18 @@ func TestRunWaitForPausePointEmbedsMatchingLogsOnHit(t *testing.T) { connection unityipc.Connection, id string, ) (pausePointStatusResponse, error) { - return pausePointStatusResponse{Id: id, Status: pausePointStatusHit, IsHit: true, HitCount: 1}, nil + return pausePointStatusResponse{ + Id: id, + Status: pausePointStatusHit, + IsHit: true, + HitCount: 1, + Generation: 7, + FirstHitAtUtc: "2026-06-03T00:00:00.1250000Z", + LastHitAtUtc: "2026-06-03T00:00:00.1250000Z", + FirstHitSequence: 3, + LastHitSequence: 3, + EditorState: pausePointEditorState{IsPlaying: true, IsPaused: true, CapturedAt: "PausePointHit"}, + }, nil } fetchedSearchText := "" @@ -404,12 +422,18 @@ func TestRunWaitForPausePointEmbedsMatchingLogsOnHit(t *testing.T) { connection unityipc.Connection, searchText string, maxCount int, - ) ([]pausePointMatchingLog, error) { + ) (pausePointMatchingLogsResult, error) { fetchedSearchText = searchText fetchedMaxCount = maxCount - return []pausePointMatchingLog{ - {Type: "Log", Message: "[jump] velocity=4.2"}, - {Type: "Log", Message: "[jump] grounded=false"}, + return pausePointMatchingLogsResult{ + SearchText: searchText, + TotalCount: 2, + DisplayedCount: 2, + MaxCount: maxCount, + Logs: []pausePointMatchingLog{ + {Type: "Log", Message: "[jump] velocity=4.2"}, + {Type: "Log", Message: "[jump] grounded=false"}, + }, }, nil } @@ -436,6 +460,20 @@ func TestRunWaitForPausePointEmbedsMatchingLogsOnHit(t *testing.T) { if len(result.MatchingLogs) != 2 || result.MatchingLogs[0].Message != "[jump] velocity=4.2" { t.Fatalf("matching logs mismatch: %#v", result.MatchingLogs) } + if result.EvidenceSummary.EditorState.CapturedAt != "PausePointHit" { + t.Fatalf("editor state summary mismatch: %#v", result.EvidenceSummary) + } + if result.EvidenceSummary.PausePoint.Generation != 7 || result.EvidenceSummary.PausePoint.FirstHitSequence != 3 { + t.Fatalf("pause point summary mismatch: %#v", result.EvidenceSummary) + } + if !result.EvidenceSummary.MatchingLogs.MultipleMatchingLogsObserved || + result.EvidenceSummary.MatchingLogs.MatchingLogCount != 2 || + result.EvidenceSummary.MatchingLogs.ReturnedLogCount != 2 { + t.Fatalf("matching log summary mismatch: %#v", result.EvidenceSummary) + } + if !strings.Contains(result.EvidenceSummary.Warning, "Multiple matching logs") { + t.Fatalf("warning mismatch: %#v", result.EvidenceSummary) + } } // Verifies a successful fetch with zero matches yields an explicit empty MatchingLogs array, @@ -453,15 +491,25 @@ func TestRunWaitForPausePointEmbedsEmptyMatchingLogsWhenNoneMatch(t *testing.T) connection unityipc.Connection, id string, ) (pausePointStatusResponse, error) { - return pausePointStatusResponse{Id: id, Status: pausePointStatusHit, IsHit: true, HitCount: 1}, nil + return pausePointStatusResponse{ + Id: id, + Status: pausePointStatusHit, + IsHit: true, + HitCount: 1, + EditorState: pausePointEditorState{IsPlaying: true, IsPaused: true, CapturedAt: "PausePointHit"}, + }, nil } fetchMatchingLogs = func( ctx context.Context, connection unityipc.Connection, searchText string, maxCount int, - ) ([]pausePointMatchingLog, error) { - return []pausePointMatchingLog{}, nil + ) (pausePointMatchingLogsResult, error) { + return pausePointMatchingLogsResult{ + SearchText: searchText, + MaxCount: maxCount, + Logs: []pausePointMatchingLog{}, + }, nil } var stdout bytes.Buffer @@ -479,6 +527,13 @@ func TestRunWaitForPausePointEmbedsEmptyMatchingLogsWhenNoneMatch(t *testing.T) if !strings.Contains(stdout.String(), "\"MatchingLogs\": []") { t.Fatalf("MatchingLogs must be an explicit empty array: %s", stdout.String()) } + var result pausePointWaitResult + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("stdout parse failed: %v from %s", err, stdout.String()) + } + if result.EvidenceSummary.Warning != "" { + t.Fatalf("warning should be empty when there are no matching logs: %#v", result.EvidenceSummary) + } } // Verifies a log fetch failure never turns a successful hit into an error. @@ -495,15 +550,21 @@ func TestRunWaitForPausePointIgnoresLogFetchFailure(t *testing.T) { connection unityipc.Connection, id string, ) (pausePointStatusResponse, error) { - return pausePointStatusResponse{Id: id, Status: pausePointStatusHit, IsHit: true, HitCount: 1}, nil + return pausePointStatusResponse{ + Id: id, + Status: pausePointStatusHit, + IsHit: true, + HitCount: 1, + EditorState: pausePointEditorState{IsPlaying: true, IsPaused: true, CapturedAt: "PausePointHit"}, + }, nil } fetchMatchingLogs = func( ctx context.Context, connection unityipc.Connection, searchText string, maxCount int, - ) ([]pausePointMatchingLog, error) { - return nil, context.DeadlineExceeded + ) (pausePointMatchingLogsResult, error) { + return pausePointMatchingLogsResult{}, context.DeadlineExceeded } var stdout bytes.Buffer @@ -521,6 +582,9 @@ func TestRunWaitForPausePointIgnoresLogFetchFailure(t *testing.T) { if strings.Contains(stdout.String(), "MatchingLogs") { t.Fatalf("MatchingLogs must be omitted when the fetch fails: %s", stdout.String()) } + if strings.Contains(stdout.String(), "EvidenceSummary") { + t.Fatalf("EvidenceSummary must be omitted when the fetch fails: %s", stdout.String()) + } } // Verifies timeout envelopes always embed marker-matching logs best-effort. @@ -542,7 +606,12 @@ func TestRunWaitForPausePointEmbedsMatchingLogsOnTimeout(t *testing.T) { connection unityipc.Connection, id string, ) (pausePointStatusResponse, error) { - return pausePointStatusResponse{Id: id, Status: pausePointStatusEnabled, IsEnabled: true, IsPlaying: true}, nil + return pausePointStatusResponse{ + Id: id, + Status: pausePointStatusEnabled, + IsEnabled: true, + EditorState: pausePointEditorState{IsPlaying: true, CapturedAt: "Current"}, + }, nil } clearPausePointStatus = func( ctx context.Context, @@ -556,8 +625,14 @@ func TestRunWaitForPausePointEmbedsMatchingLogsOnTimeout(t *testing.T) { connection unityipc.Connection, searchText string, maxCount int, - ) ([]pausePointMatchingLog, error) { - return []pausePointMatchingLog{{Type: "Log", Message: "[jump] never reached"}}, nil + ) (pausePointMatchingLogsResult, error) { + return pausePointMatchingLogsResult{ + SearchText: searchText, + TotalCount: 3, + DisplayedCount: 1, + MaxCount: maxCount, + Logs: []pausePointMatchingLog{{Type: "Log", Message: "[jump] never reached"}}, + }, nil } var stdout bytes.Buffer @@ -578,6 +653,14 @@ func TestRunWaitForPausePointEmbedsMatchingLogsOnTimeout(t *testing.T) { if !ok || len(matchingLogs) != 1 { t.Fatalf("MatchingLogs detail mismatch: %#v", envelope.Error.Details) } + evidenceSummary, ok := envelope.Error.Details["EvidenceSummary"].(map[string]any) + if !ok { + t.Fatalf("EvidenceSummary detail missing: %#v", envelope.Error.Details) + } + evidenceLogs, ok := evidenceSummary["MatchingLogs"].(map[string]any) + if !ok || evidenceLogs["MayBeTruncated"] != true || evidenceLogs["MatchingLogCount"] != float64(3) { + t.Fatalf("EvidenceSummary matching logs mismatch: %#v", evidenceSummary) + } } // Verifies timeout errors include a deterministic diagnosis hint for common stuck states. @@ -589,17 +672,26 @@ func TestPausePointTimeoutErrorIncludesDiagnosisHint(t *testing.T) { }{ { name: "play mode not running", - response: pausePointStatusResponse{Id: "jump", Status: pausePointStatusEnabled, IsPlaying: false}, + response: pausePointStatusResponse{Id: "jump", Status: pausePointStatusEnabled}, wantHint: "PlayMode is not running. Start PlayMode (or trigger the marker code path in Edit Mode), then wait again.", }, { - name: "editor already paused", - response: pausePointStatusResponse{Id: "jump", Status: pausePointStatusEnabled, IsPlaying: true, IsPaused: true}, + name: "editor already paused", + response: pausePointStatusResponse{ + Id: "jump", + Status: pausePointStatusEnabled, + EditorState: pausePointEditorState{IsPlaying: true, IsPaused: true, CapturedAt: "Current"}, + }, wantHint: "Unity is already paused, so gameplay cannot reach the marker. Resume PlayMode before waiting again.", }, { - name: "marker never hit", - response: pausePointStatusResponse{Id: "jump", Status: pausePointStatusEnabled, IsPlaying: true, HitCount: 0}, + name: "marker never hit", + response: pausePointStatusResponse{ + Id: "jump", + Status: pausePointStatusEnabled, + EditorState: pausePointEditorState{IsPlaying: true, CapturedAt: "Current"}, + HitCount: 0, + }, wantHint: "Marker was enabled but never hit. Confirm the id matches UloopPausePoint.Pause(\"\") and that the code path was executed. In fast-progressing games the state may have already moved past the marker (for example back to Ready or GameOver), so re-trigger the code path and wait again.", }, } @@ -628,17 +720,26 @@ func TestPausePointExpiredErrorIncludesDiagnosisHint(t *testing.T) { }{ { name: "play mode not running", - response: pausePointStatusResponse{Id: "jump", Status: pausePointStatusExpired, IsPlaying: false}, + response: pausePointStatusResponse{Id: "jump", Status: pausePointStatusExpired}, wantHint: "PlayMode is not running. Start PlayMode (or trigger the marker code path in Edit Mode), then wait again.", }, { - name: "editor already paused", - response: pausePointStatusResponse{Id: "jump", Status: pausePointStatusExpired, IsPlaying: true, IsPaused: true}, + name: "editor already paused", + response: pausePointStatusResponse{ + Id: "jump", + Status: pausePointStatusExpired, + EditorState: pausePointEditorState{IsPlaying: true, IsPaused: true, CapturedAt: "Current"}, + }, wantHint: "Unity is already paused, so gameplay cannot reach the marker. Resume PlayMode before waiting again.", }, { - name: "marker expired before hit", - response: pausePointStatusResponse{Id: "jump", Status: pausePointStatusExpired, IsPlaying: true, HitCount: 0}, + name: "marker expired before hit", + response: pausePointStatusResponse{ + Id: "jump", + Status: pausePointStatusExpired, + EditorState: pausePointEditorState{IsPlaying: true, CapturedAt: "Current"}, + HitCount: 0, + }, wantHint: "Marker expired before it was hit: the enable-pause-point --timeout-seconds window (measured from enable, not from this wait) ran out. Re-enable the marker with a longer --timeout-seconds and trigger the code path again.", }, } @@ -659,7 +760,12 @@ func TestPausePointExpiredErrorIncludesDiagnosisHint(t *testing.T) { // Verifies hints stay scoped to diagnosable response states. func TestPausePointHintIsOmittedOutsideDiagnosableStates(t *testing.T) { - hitResponse := pausePointStatusResponse{Id: "jump", Status: pausePointStatusHit, IsPlaying: true, HitCount: 1} + hitResponse := pausePointStatusResponse{ + Id: "jump", + Status: pausePointStatusHit, + EditorState: pausePointEditorState{IsPlaying: true, CapturedAt: "PausePointHit"}, + HitCount: 1, + } timeoutErr := pausePointWaitError("/tmp/MyProject", waitForPausePointOptions{ id: "jump", timeoutSeconds: 1, @@ -668,7 +774,11 @@ func TestPausePointHintIsOmittedOutsideDiagnosableStates(t *testing.T) { t.Fatalf("hint should be omitted when no diagnosis applies: %#v", timeoutErr.Details) } - clearedResponse := pausePointStatusResponse{Id: "jump", Status: pausePointStatusCleared, IsPlaying: true} + clearedResponse := pausePointStatusResponse{ + Id: "jump", + Status: pausePointStatusCleared, + EditorState: pausePointEditorState{IsPlaying: true, CapturedAt: "Current"}, + } clearedErr := pausePointWaitError("/tmp/MyProject", waitForPausePointOptions{ id: "jump", timeoutSeconds: 1, @@ -685,7 +795,7 @@ func TestPausePointExpiredErrorReportsNoRemainingTime(t *testing.T) { Status: pausePointStatusExpired, TimeoutSeconds: 1, ElapsedSinceEnabledMilliseconds: 1200, - IsPlaying: true, + EditorState: pausePointEditorState{IsPlaying: true, CapturedAt: "Current"}, Message: "Pause point expired before it was hit.", } @@ -759,6 +869,7 @@ func TestRunPausePointStatusReturnsCurrentStatus(t *testing.T) { EnabledAtUtc: "2026-06-03T00:00:00.0000000Z", RemainingMilliseconds: 30000, Generation: 3, + EditorState: pausePointEditorState{IsPlaying: true, CapturedAt: "Current"}, RecommendedNextAction: "Clear this marker, then re-enable it with the same Id and TimeoutSeconds values.", }, nil } @@ -791,6 +902,9 @@ func TestRunPausePointStatusReturnsCurrentStatus(t *testing.T) { if response.Generation != 3 { t.Fatalf("generation mismatch: %#v", response) } + if response.EditorState.CapturedAt != "Current" || !response.EditorState.IsPlaying { + t.Fatalf("editor state mismatch: %#v", response) + } if response.RecommendedNextAction != "Clear this marker, then re-enable it with the same Id and TimeoutSeconds values." { t.Fatalf("recommendedNextAction mismatch: %#v", response) } From d2e28facec21aa9c9de00360be3a10945ef02019 Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 15 Jun 2026 00:42:55 +0900 Subject: [PATCH 2/3] Refresh generated pause point skills Keep the checked-in Claude and Agents skill copies aligned with the updated pause point evidence contract so agents see EvidenceSummary and EditorState guidance. --- .agents/skills/uloop-wait-for-pause-point/SKILL.md | 8 ++++++-- .claude/skills/uloop-wait-for-pause-point/SKILL.md | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.agents/skills/uloop-wait-for-pause-point/SKILL.md b/.agents/skills/uloop-wait-for-pause-point/SKILL.md index 74981cf37b..794950b40a 100644 --- a/.agents/skills/uloop-wait-for-pause-point/SKILL.md +++ b/.agents/skills/uloop-wait-for-pause-point/SKILL.md @@ -32,8 +32,12 @@ uloop wait-for-pause-point --id state-transition-applied --timeout-seconds 30 The hit response always embeds the log entries matching the marker id as `MatchingLogs` (`--matching-logs-max-count` adjusts the limit, default 10), so a separate `get-logs` call while paused is unnecessary. Log embedding is always on; there is no opt-in flag, and a `--include-matching-logs` option no longer exists. An empty `MatchingLogs` array means the fetch succeeded and no matching log exists; if the field is absent, the log fetch itself failed, so fall back to `uloop get-logs --search-text state-transition-applied --max-count 20` while paused. +Read `EvidenceSummary` first when it is present. It groups `EditorState`, pause point hit metadata, matching-log counts, truncation status, and warnings so you can tell whether the evidence is a single clean hit or needs closer inspection. `EditorState.CapturedAt` names when the Unity Editor play/pause state was observed, such as `PausePointHit`, `Current`, or `ClearAll`. + +Use `Generation`, `EnabledAtUtc`, and the hit sequence fields from the hit or status response to tell a fresh marker from stale evidence with the same id. `RemainingMilliseconds` and `Expired` are returned directly so you do not need to infer marker lifetime from elapsed time. + 5. While Unity is still paused, capture any additional evidence with `uloop execute-dynamic-code`, `uloop get-hierarchy`, `uloop find-game-objects`, and one screenshot. -6. Clear the marker with `uloop clear-pause-point --id state-transition-applied` or stop PlayMode before moving on. Use `uloop clear-pause-point --all` to clear every active marker at once, for example when resetting between E2E scenarios. +6. Clear the marker with `uloop clear-pause-point --id state-transition-applied` or stop PlayMode before moving on. Use `uloop clear-pause-point --all` to clear every active marker at once, for example when resetting between E2E scenarios. The clear response's `EditorState` describes Unity Editor play/pause state, not marker state. ## When To Use @@ -48,7 +52,7 @@ The hit response always embeds the log entries matching the marker id as `Matchi ## Timeout Checks -If this command times out, the marker line was not reached while the command waited. Read `error.details.hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. A `PAUSE_POINT_EXPIRED` error carries the same hint: it means the marker's own `enable-pause-point --timeout-seconds` window (measured from enable, not from wait) ran out first, so re-enable the marker with a longer timeout. Then inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, runtime conditions not being met, an id mismatch, or Unity already being paused. `error.details.MatchingLogs` shows whether the marker's focused log ever appeared. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. +If this command times out, the marker line was not reached while the command waited. Read `error.details.hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. A `PAUSE_POINT_EXPIRED` error carries the same hint and shell-neutral `error.details.recommendedNextAction`: it means the marker's own `enable-pause-point --timeout-seconds` window (measured from enable, not from wait) ran out first, so clear and re-enable the marker using the returned `id` and `timeoutSeconds`. Then inspect `error.details.status`, `hitCount`, `generation`, `enabledAtUtc`, `editorState`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, stale evidence from an older marker generation, runtime conditions not being met, an id mismatch, or Unity already being paused. `error.details.MatchingLogs` and `error.details.EvidenceSummary` show whether the marker's focused log ever appeared. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. Use `uloop pause-point-status --id state-transition-applied` only when you need to confirm the marker is armed or inspect the current hit state. Add focused debug logs before the marker when local variables must be captured. diff --git a/.claude/skills/uloop-wait-for-pause-point/SKILL.md b/.claude/skills/uloop-wait-for-pause-point/SKILL.md index 74981cf37b..794950b40a 100644 --- a/.claude/skills/uloop-wait-for-pause-point/SKILL.md +++ b/.claude/skills/uloop-wait-for-pause-point/SKILL.md @@ -32,8 +32,12 @@ uloop wait-for-pause-point --id state-transition-applied --timeout-seconds 30 The hit response always embeds the log entries matching the marker id as `MatchingLogs` (`--matching-logs-max-count` adjusts the limit, default 10), so a separate `get-logs` call while paused is unnecessary. Log embedding is always on; there is no opt-in flag, and a `--include-matching-logs` option no longer exists. An empty `MatchingLogs` array means the fetch succeeded and no matching log exists; if the field is absent, the log fetch itself failed, so fall back to `uloop get-logs --search-text state-transition-applied --max-count 20` while paused. +Read `EvidenceSummary` first when it is present. It groups `EditorState`, pause point hit metadata, matching-log counts, truncation status, and warnings so you can tell whether the evidence is a single clean hit or needs closer inspection. `EditorState.CapturedAt` names when the Unity Editor play/pause state was observed, such as `PausePointHit`, `Current`, or `ClearAll`. + +Use `Generation`, `EnabledAtUtc`, and the hit sequence fields from the hit or status response to tell a fresh marker from stale evidence with the same id. `RemainingMilliseconds` and `Expired` are returned directly so you do not need to infer marker lifetime from elapsed time. + 5. While Unity is still paused, capture any additional evidence with `uloop execute-dynamic-code`, `uloop get-hierarchy`, `uloop find-game-objects`, and one screenshot. -6. Clear the marker with `uloop clear-pause-point --id state-transition-applied` or stop PlayMode before moving on. Use `uloop clear-pause-point --all` to clear every active marker at once, for example when resetting between E2E scenarios. +6. Clear the marker with `uloop clear-pause-point --id state-transition-applied` or stop PlayMode before moving on. Use `uloop clear-pause-point --all` to clear every active marker at once, for example when resetting between E2E scenarios. The clear response's `EditorState` describes Unity Editor play/pause state, not marker state. ## When To Use @@ -48,7 +52,7 @@ The hit response always embeds the log entries matching the marker id as `Matchi ## Timeout Checks -If this command times out, the marker line was not reached while the command waited. Read `error.details.hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. A `PAUSE_POINT_EXPIRED` error carries the same hint: it means the marker's own `enable-pause-point --timeout-seconds` window (measured from enable, not from wait) ran out first, so re-enable the marker with a longer timeout. Then inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, runtime conditions not being met, an id mismatch, or Unity already being paused. `error.details.MatchingLogs` shows whether the marker's focused log ever appeared. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. +If this command times out, the marker line was not reached while the command waited. Read `error.details.hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. A `PAUSE_POINT_EXPIRED` error carries the same hint and shell-neutral `error.details.recommendedNextAction`: it means the marker's own `enable-pause-point --timeout-seconds` window (measured from enable, not from wait) ran out first, so clear and re-enable the marker using the returned `id` and `timeoutSeconds`. Then inspect `error.details.status`, `hitCount`, `generation`, `enabledAtUtc`, `editorState`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, stale evidence from an older marker generation, runtime conditions not being met, an id mismatch, or Unity already being paused. `error.details.MatchingLogs` and `error.details.EvidenceSummary` show whether the marker's focused log ever appeared. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. Use `uloop pause-point-status --id state-transition-applied` only when you need to confirm the marker is armed or inspect the current hit state. Add focused debug logs before the marker when local variables must be captured. From 1d691a4ab57703e9448f3ea603deb70bff8c275b Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 15 Jun 2026 01:14:55 +0900 Subject: [PATCH 3/3] Clarify pause point evidence wording Use observed wording for matching-log counts that may exceed returned rows, and limit skill guidance for timeout evidence summaries to the error that actually includes them. --- .agents/skills/uloop-wait-for-pause-point/SKILL.md | 2 +- .claude/skills/uloop-wait-for-pause-point/SKILL.md | 2 +- Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md | 2 +- cli/internal/cli/pause_point_logs.go | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/skills/uloop-wait-for-pause-point/SKILL.md b/.agents/skills/uloop-wait-for-pause-point/SKILL.md index 794950b40a..d899a4f0ff 100644 --- a/.agents/skills/uloop-wait-for-pause-point/SKILL.md +++ b/.agents/skills/uloop-wait-for-pause-point/SKILL.md @@ -52,7 +52,7 @@ Use `Generation`, `EnabledAtUtc`, and the hit sequence fields from the hit or st ## Timeout Checks -If this command times out, the marker line was not reached while the command waited. Read `error.details.hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. A `PAUSE_POINT_EXPIRED` error carries the same hint and shell-neutral `error.details.recommendedNextAction`: it means the marker's own `enable-pause-point --timeout-seconds` window (measured from enable, not from wait) ran out first, so clear and re-enable the marker using the returned `id` and `timeoutSeconds`. Then inspect `error.details.status`, `hitCount`, `generation`, `enabledAtUtc`, `editorState`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, stale evidence from an older marker generation, runtime conditions not being met, an id mismatch, or Unity already being paused. `error.details.MatchingLogs` and `error.details.EvidenceSummary` show whether the marker's focused log ever appeared. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. +If this command times out, the marker line was not reached while the command waited. Read `error.details.hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. A `PAUSE_POINT_EXPIRED` error carries the same hint and shell-neutral `error.details.recommendedNextAction`: it means the marker's own `enable-pause-point --timeout-seconds` window (measured from enable, not from wait) ran out first, so clear and re-enable the marker using the returned `id` and `timeoutSeconds`. Then inspect `error.details.status`, `hitCount`, `generation`, `enabledAtUtc`, `editorState`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, stale evidence from an older marker generation, runtime conditions not being met, an id mismatch, or Unity already being paused. On `PAUSE_POINT_WAIT_TIMEOUT`, `error.details.MatchingLogs` and `error.details.EvidenceSummary` show whether the marker's focused log ever appeared. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. Use `uloop pause-point-status --id state-transition-applied` only when you need to confirm the marker is armed or inspect the current hit state. Add focused debug logs before the marker when local variables must be captured. diff --git a/.claude/skills/uloop-wait-for-pause-point/SKILL.md b/.claude/skills/uloop-wait-for-pause-point/SKILL.md index 794950b40a..d899a4f0ff 100644 --- a/.claude/skills/uloop-wait-for-pause-point/SKILL.md +++ b/.claude/skills/uloop-wait-for-pause-point/SKILL.md @@ -52,7 +52,7 @@ Use `Generation`, `EnabledAtUtc`, and the hit sequence fields from the hit or st ## Timeout Checks -If this command times out, the marker line was not reached while the command waited. Read `error.details.hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. A `PAUSE_POINT_EXPIRED` error carries the same hint and shell-neutral `error.details.recommendedNextAction`: it means the marker's own `enable-pause-point --timeout-seconds` window (measured from enable, not from wait) ran out first, so clear and re-enable the marker using the returned `id` and `timeoutSeconds`. Then inspect `error.details.status`, `hitCount`, `generation`, `enabledAtUtc`, `editorState`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, stale evidence from an older marker generation, runtime conditions not being met, an id mismatch, or Unity already being paused. `error.details.MatchingLogs` and `error.details.EvidenceSummary` show whether the marker's focused log ever appeared. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. +If this command times out, the marker line was not reached while the command waited. Read `error.details.hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. A `PAUSE_POINT_EXPIRED` error carries the same hint and shell-neutral `error.details.recommendedNextAction`: it means the marker's own `enable-pause-point --timeout-seconds` window (measured from enable, not from wait) ran out first, so clear and re-enable the marker using the returned `id` and `timeoutSeconds`. Then inspect `error.details.status`, `hitCount`, `generation`, `enabledAtUtc`, `editorState`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, stale evidence from an older marker generation, runtime conditions not being met, an id mismatch, or Unity already being paused. On `PAUSE_POINT_WAIT_TIMEOUT`, `error.details.MatchingLogs` and `error.details.EvidenceSummary` show whether the marker's focused log ever appeared. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. Use `uloop pause-point-status --id state-transition-applied` only when you need to confirm the marker is armed or inspect the current hit state. Add focused debug logs before the marker when local variables must be captured. diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md index 794950b40a..d899a4f0ff 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -52,7 +52,7 @@ Use `Generation`, `EnabledAtUtc`, and the hit sequence fields from the hit or st ## Timeout Checks -If this command times out, the marker line was not reached while the command waited. Read `error.details.hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. A `PAUSE_POINT_EXPIRED` error carries the same hint and shell-neutral `error.details.recommendedNextAction`: it means the marker's own `enable-pause-point --timeout-seconds` window (measured from enable, not from wait) ran out first, so clear and re-enable the marker using the returned `id` and `timeoutSeconds`. Then inspect `error.details.status`, `hitCount`, `generation`, `enabledAtUtc`, `editorState`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, stale evidence from an older marker generation, runtime conditions not being met, an id mismatch, or Unity already being paused. `error.details.MatchingLogs` and `error.details.EvidenceSummary` show whether the marker's focused log ever appeared. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. +If this command times out, the marker line was not reached while the command waited. Read `error.details.hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. A `PAUSE_POINT_EXPIRED` error carries the same hint and shell-neutral `error.details.recommendedNextAction`: it means the marker's own `enable-pause-point --timeout-seconds` window (measured from enable, not from wait) ran out first, so clear and re-enable the marker using the returned `id` and `timeoutSeconds`. Then inspect `error.details.status`, `hitCount`, `generation`, `enabledAtUtc`, `editorState`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, stale evidence from an older marker generation, runtime conditions not being met, an id mismatch, or Unity already being paused. On `PAUSE_POINT_WAIT_TIMEOUT`, `error.details.MatchingLogs` and `error.details.EvidenceSummary` show whether the marker's focused log ever appeared. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. Use `uloop pause-point-status --id state-transition-applied` only when you need to confirm the marker is armed or inspect the current hit state. Add focused debug logs before the marker when local variables must be captured. diff --git a/cli/internal/cli/pause_point_logs.go b/cli/internal/cli/pause_point_logs.go index f43adba0f3..8bb57ebc59 100644 --- a/cli/internal/cli/pause_point_logs.go +++ b/cli/internal/cli/pause_point_logs.go @@ -173,7 +173,7 @@ func buildPausePointEvidenceWarning(logs pausePointMatchingLogsResult, hitCount if evidenceLogs.MultipleMatchingLogsObserved { warnings = append( warnings, - "Multiple matching logs were returned for this pause point id; inspect MatchingLogs before treating the scenario as single-fire evidence.") + "Multiple matching logs were observed for this pause point id; inspect MatchingLogs before treating the scenario as single-fire evidence.") } if hitCount > 1 { warnings = append(