diff --git a/.agents/skills/uloop-wait-for-pause-point/SKILL.md b/.agents/skills/uloop-wait-for-pause-point/SKILL.md index 74981cf37b..d899a4f0ff 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. 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 74981cf37b..d899a4f0ff 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. 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/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..d899a4f0ff 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. 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/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..8bb57ebc59 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 observed 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) }