Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 72 additions & 2 deletions Assets/Tests/Editor/PausePointTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using UnityEditor;

using io.github.hatayama.UnityCliLoop.FirstPartyTools;
using io.github.hatayama.UnityCliLoop.Infrastructure;
using io.github.hatayama.UnityCliLoop.Runtime;

namespace io.github.hatayama.UnityCliLoop.Tests.Editor
Expand Down Expand Up @@ -124,20 +125,54 @@ public void GetStatus_WhenTimeoutPasses_ExpiresAndDisarms()
UloopPausePoint.Pause("jump");

Assert.That(snapshot.Status, Is.EqualTo(UloopPausePointStatus.Expired));
Assert.That(snapshot.Expired, Is.True);
Assert.That(snapshot.RemainingMilliseconds, Is.EqualTo(0));
Assert.That(
snapshot.RecommendedNextAction,
Is.EqualTo("Clear this marker, then re-enable it with the same Id and TimeoutSeconds values."));
Assert.That(snapshot.IsEnabled, Is.False);
Assert.That(_pauseController.PauseCount, Is.EqualTo(0));
}

[Test]
public void GetStatus_WhenEnabled_ReportsElapsedSinceEnabledMilliseconds()
public void GetStatus_WhenExpiredIdContainsShellSyntax_ReturnsShellNeutralRecoveryAction()
{
// Verifies elapsed time is named as time since the marker was enabled.
// Verifies recovery guidance does not embed shell syntax that differs between user environments.
UloopPausePointRegistry.Enable("jump && other-command", 1);
_nowUtc = _nowUtc.AddSeconds(2);

UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("jump && other-command");

Assert.That(
snapshot.RecommendedNextAction,
Is.EqualTo("Clear this marker, then re-enable it with the same Id and TimeoutSeconds values."));
}

[Test]
public void GetStatus_WhenEnabled_ReportsTimingAndGenerationFields()
{
// Verifies status reports the marker lifetime and generation without making callers recompute it.
UloopPausePointRegistry.Enable("jump", 30);
_nowUtc = _nowUtc.AddMilliseconds(250);

UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("jump");

Assert.That(snapshot.EnabledAtUtc, Is.EqualTo("2026-06-03T00:00:00.0000000Z"));
Assert.That(snapshot.ElapsedSinceEnabledMilliseconds, Is.EqualTo(250));
Assert.That(snapshot.RemainingMilliseconds, Is.EqualTo(29750));
Assert.That(snapshot.Generation, Is.EqualTo(1));
Assert.That(snapshot.Expired, Is.False);
}

[Test]
public void Enable_WhenMarkerIsReenabled_IncrementsGeneration()
{
// Verifies callers can distinguish a fresh marker from stale status or log evidence with the same id.
UloopPausePointSnapshot firstSnapshot = UloopPausePointRegistry.Enable("jump", 30);
UloopPausePointSnapshot secondSnapshot = UloopPausePointRegistry.Enable("jump", 30);

Assert.That(firstSnapshot.Generation, Is.EqualTo(1));
Assert.That(secondSnapshot.Generation, Is.EqualTo(2));
}

[Test]
Expand Down Expand Up @@ -206,6 +241,41 @@ public async Task ClearAll_WhenNothingActive_ReportsNoActiveMessage()
Assert.That(response.Message, Is.EqualTo("No active pause points to clear."));
}

[Test]
public async Task Enable_WhenMarkerCreated_ReturnsStateManagementFields()
{
// Verifies the public enable-pause-point tool exposes timing and generation fields from the registry.
PausePointResponse response = await EnablePausePointAsync("jump");

Assert.That(response.EnabledAtUtc, Is.EqualTo("2026-06-03T00:00:00.0000000Z"));
Assert.That(response.RemainingMilliseconds, Is.EqualTo(30000));
Assert.That(response.Generation, Is.EqualTo(1));
Assert.That(response.Expired, Is.False);
Assert.That(response.RecommendedNextAction, Is.Empty);
}

[Test]
public void PausePointStatusBridge_WhenMarkerExpired_ReturnsRecoveryAction()
{
// Verifies pause-point-status exposes enough data to re-arm an expired marker without guesswork.
UloopPausePointRegistry.Enable("jump", 1);
_nowUtc = _nowUtc.AddSeconds(2);
JObject parameters = new()
{
["id"] = "jump"
};

PausePointStatusResponse response = PausePointStatusBridgeCommand.Execute(parameters);

Assert.That(response.Expired, Is.True);
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.RecommendedNextAction,
Is.EqualTo("Clear this marker, then re-enable it with the same Id and TimeoutSeconds values."));
}

[Test]
public void Enable_WhenSamePausePointWasHit_ClearsLatestHitSnapshot()
{
Expand Down
4 changes: 3 additions & 1 deletion Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ 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.

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.

Expand All @@ -48,7 +50,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`, `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`.

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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,16 @@ public class PausePointResponse : UnityCliLoopToolResponse
public bool IsHit { get; set; }
public int HitCount { get; set; }
public int TimeoutSeconds { get; set; }
public bool Expired { get; set; }
public string EnabledAtUtc { get; set; } = string.Empty;
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 int ClearedCount { get; set; }
public string Message { get; set; } = string.Empty;
public string RecommendedNextAction { get; set; } = string.Empty;
public string Warning { get; set; } = string.Empty;

internal static PausePointResponse FromSnapshot(UloopPausePointSnapshot snapshot)
Expand All @@ -61,10 +66,15 @@ internal static PausePointResponse FromSnapshot(UloopPausePointSnapshot snapshot
IsHit = snapshot.IsHit,
HitCount = snapshot.HitCount,
TimeoutSeconds = snapshot.TimeoutSeconds,
Expired = snapshot.Expired,
EnabledAtUtc = snapshot.EnabledAtUtc,
ElapsedSinceEnabledMilliseconds = snapshot.ElapsedSinceEnabledMilliseconds,
RemainingMilliseconds = snapshot.RemainingMilliseconds,
Generation = snapshot.Generation,
IsPlaying = snapshot.IsPlaying,
IsPaused = snapshot.IsPaused,
Message = snapshot.Message
Message = snapshot.Message,
RecommendedNextAction = snapshot.RecommendedNextAction
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,15 @@ public class PausePointStatusResponse : UnityCliLoopToolResponse
public bool IsHit { get; set; }
public int HitCount { get; set; }
public int TimeoutSeconds { get; set; }
public bool Expired { get; set; }
public string EnabledAtUtc { get; set; } = string.Empty;
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 string Message { get; set; } = string.Empty;
public string RecommendedNextAction { get; set; } = string.Empty;

internal static PausePointStatusResponse FromSnapshot(UloopPausePointSnapshot snapshot)
{
Expand All @@ -70,10 +75,15 @@ internal static PausePointStatusResponse FromSnapshot(UloopPausePointSnapshot sn
IsHit = snapshot.IsHit,
HitCount = snapshot.HitCount,
TimeoutSeconds = snapshot.TimeoutSeconds,
Expired = snapshot.Expired,
EnabledAtUtc = snapshot.EnabledAtUtc,
ElapsedSinceEnabledMilliseconds = snapshot.ElapsedSinceEnabledMilliseconds,
RemainingMilliseconds = snapshot.RemainingMilliseconds,
Generation = snapshot.Generation,
IsPlaying = snapshot.IsPlaying,
IsPaused = snapshot.IsPaused,
Message = snapshot.Message
Message = snapshot.Message,
RecommendedNextAction = snapshot.RecommendedNextAction
};
}
}
Expand Down
65 changes: 60 additions & 5 deletions Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ internal static class UloopPausePointRegistry
private static readonly Dictionary<string, UloopPausePointEntry> Entries = new();
private static IUloopPausePointPauseController _pauseController = new UnityEditorPausePointPauseController();
private static Func<DateTime> _nowProvider = () => DateTime.UtcNow;
private static int _nextGeneration;
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.
Expand All @@ -27,7 +28,8 @@ public static UloopPausePointSnapshot Enable(string id, int timeoutSeconds)
Debug.Assert(timeoutSeconds > 0, "timeoutSeconds must be greater than zero");

DateTime now = NowUtc();
UloopPausePointEntry entry = new(id, timeoutSeconds, now);
int generation = ++_nextGeneration;
UloopPausePointEntry entry = new(id, timeoutSeconds, now, generation);
Entries[id] = entry;
ClearLatestHitSnapshotIfMatches(id);
return entry.ToSnapshot(now, _pauseController);
Expand Down Expand Up @@ -166,6 +168,7 @@ public static void ConfigureForTests(IUloopPausePointPauseController pauseContro
public static void ResetForTests()
{
Entries.Clear();
_nextGeneration = 0;
_latestHitSnapshot = null;
_hitSnapshots.Clear();
_pauseController = new UnityEditorPausePointPauseController();
Expand Down Expand Up @@ -215,21 +218,31 @@ public UloopPausePointSnapshot(
bool isHit,
int hitCount,
int timeoutSeconds,
bool expired,
string enabledAtUtc,
long elapsedMilliseconds,
long remainingMilliseconds,
int generation,
bool isPlaying,
bool isPaused,
string message)
string message,
string recommendedNextAction)
{
Id = id ?? string.Empty;
Status = status ?? UloopPausePointStatus.NotEnabled;
IsEnabled = isEnabled;
IsHit = isHit;
HitCount = hitCount;
TimeoutSeconds = timeoutSeconds;
Expired = expired;
EnabledAtUtc = enabledAtUtc ?? string.Empty;
ElapsedSinceEnabledMilliseconds = elapsedMilliseconds;
RemainingMilliseconds = remainingMilliseconds;
Generation = generation;
IsPlaying = isPlaying;
IsPaused = isPaused;
Message = message ?? string.Empty;
RecommendedNextAction = recommendedNextAction ?? string.Empty;
}

public string Id { get; }
Expand All @@ -238,10 +251,15 @@ public UloopPausePointSnapshot(
public bool IsHit { get; }
public int HitCount { get; }
public int TimeoutSeconds { get; }
public bool Expired { get; }
public string EnabledAtUtc { get; }
public long ElapsedSinceEnabledMilliseconds { get; }
public long RemainingMilliseconds { get; }
public int Generation { get; }
public bool IsPlaying { get; }
public bool IsPaused { get; }
public string Message { get; }
public string RecommendedNextAction { get; }

public static UloopPausePointSnapshot NotEnabled(string id, IUloopPausePointPauseController pauseController)
{
Expand All @@ -254,10 +272,15 @@ public static UloopPausePointSnapshot NotEnabled(string id, IUloopPausePointPaus
false,
0,
0,
false,
string.Empty,
0,
0,
0,
pauseController.IsPlaying,
pauseController.IsPaused,
"Pause point is not enabled.");
"Pause point is not enabled.",
string.Empty);
}
}

Expand All @@ -281,12 +304,13 @@ public UloopPausePointClearAllResult(int clearedCount, DateTime clearedAtUtc)
/// </summary>
internal sealed class UloopPausePointEntry
{
public UloopPausePointEntry(string id, int timeoutSeconds, DateTime enabledAtUtc)
public UloopPausePointEntry(string id, int timeoutSeconds, DateTime enabledAtUtc, int generation)
{
Id = id;
TimeoutSeconds = timeoutSeconds;
EnabledAtUtc = enabledAtUtc;
ExpiresAtUtc = enabledAtUtc.AddSeconds(timeoutSeconds);
Generation = generation;
Status = UloopPausePointStatus.Enabled;
IsEnabled = true;
Message = "Pause point enabled.";
Expand All @@ -296,6 +320,7 @@ public UloopPausePointEntry(string id, int timeoutSeconds, DateTime enabledAtUtc
public int TimeoutSeconds { get; }
public DateTime EnabledAtUtc { get; }
public DateTime ExpiresAtUtc { get; }
public int Generation { get; }
public string Status { get; private set; }
public bool IsEnabled { get; private set; }
public int HitCount { get; private set; }
Expand Down Expand Up @@ -344,9 +369,12 @@ public UloopPausePointSnapshot ToSnapshot(DateTime nowUtc, IUloopPausePointPause
Debug.Assert(pauseController != null, "pauseController must not be null");

bool isHit = Status == UloopPausePointStatus.Hit;
bool expired = Status == UloopPausePointStatus.Expired;
bool isPlaying = isHit ? IsPlayingAtHit : pauseController.IsPlaying;
bool isPaused = isHit ? IsPausedAtHit : pauseController.IsPaused;
long elapsedMilliseconds = Math.Max(0, (long)(nowUtc - EnabledAtUtc).TotalMilliseconds);
long remainingMilliseconds = CalculateRemainingMilliseconds(nowUtc);
string recommendedNextAction = expired ? CreateExpiredRecommendedNextAction() : string.Empty;

return new UloopPausePointSnapshot(
Id,
Expand All @@ -355,10 +383,37 @@ public UloopPausePointSnapshot ToSnapshot(DateTime nowUtc, IUloopPausePointPause
isHit,
HitCount,
TimeoutSeconds,
expired,
FormatUtc(EnabledAtUtc),
elapsedMilliseconds,
remainingMilliseconds,
Generation,
isPlaying,
isPaused,
Message);
Message,
recommendedNextAction);
}

private long CalculateRemainingMilliseconds(DateTime nowUtc)
{
if (!IsEnabled)
{
return 0;
}

long remainingMilliseconds = (long)(ExpiresAtUtc - nowUtc).TotalMilliseconds;
return Math.Max(0, remainingMilliseconds);
}

private string CreateExpiredRecommendedNextAction()
{
return "Clear this marker, then re-enable it with the same Id and TimeoutSeconds values.";
}

private static string FormatUtc(DateTime value)
{
DateTime utcValue = value.Kind == DateTimeKind.Utc ? value : value.ToUniversalTime();
return utcValue.ToString("O");
}
}

Expand Down
Loading
Loading