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)
}