diff --git a/Assets/Tests/Editor/PausePointCaptureModeTests.cs b/Assets/Tests/Editor/PausePointCaptureModeTests.cs index 55b2c49f86..86ddd8b880 100644 --- a/Assets/Tests/Editor/PausePointCaptureModeTests.cs +++ b/Assets/Tests/Editor/PausePointCaptureModeTests.cs @@ -172,7 +172,8 @@ private static UloopCapturedVariable CreateVariable(string name, string value) value, string.Empty, string.Empty, - 0); + 0, + truncated: false); } private sealed class FakePausePointPauseController : IUloopPausePointPauseController diff --git a/Assets/Tests/Editor/PausePointStatusResponseContractTests.cs b/Assets/Tests/Editor/PausePointStatusResponseContractTests.cs index d65130ff6b..0ad84a117e 100644 --- a/Assets/Tests/Editor/PausePointStatusResponseContractTests.cs +++ b/Assets/Tests/Editor/PausePointStatusResponseContractTests.cs @@ -25,6 +25,7 @@ public void PausePointStatusResponse_WhenSerialized_MatchesSharedContractFieldSh JObject expected = ReadSharedContractFieldShape(); PausePointStatusResponse response = new() { + Success = true, Id = "Assets/Scripts/Enemy.cs:42", Status = "Hit", IsEnabled = true, @@ -33,6 +34,7 @@ public void PausePointStatusResponse_WhenSerialized_MatchesSharedContractFieldSh TimeoutSeconds = 30, Mode = "continuous", MaxHistory = 20, + MaxPreviewElements = 15, CapturedVariableHistory = new List { new() @@ -72,10 +74,13 @@ public void PausePointStatusResponse_WhenSerialized_MatchesSharedContractFieldSh Value = "Enemy", UnityObjectKind = "SceneObject", UnityObjectPath = "MainScene:/Root/Enemy", - UnityObjectInstanceId = -1234 + UnityObjectInstanceId = -1234, + Truncated = false } }, CapturedVariablesTruncated = true, + TruncatedVariableNames = new[] { "extraField" }, + TruncatedVariableCount = 1, ClearedReason = "", StatusBeforeClear = "", LateHitDiscardedAfterClear = false diff --git a/Assets/Tests/Editor/PausePointTests.cs b/Assets/Tests/Editor/PausePointTests.cs index 3c50cac858..8803b612e4 100644 --- a/Assets/Tests/Editor/PausePointTests.cs +++ b/Assets/Tests/Editor/PausePointTests.cs @@ -888,7 +888,7 @@ public void PausePointStatusBridge_WhenPausePointHitWithCapturedVariables_Return UloopPausePointRegistry.Enable("jump", 30); UloopCapturedVariable[] capturedVariables = { - new("speed", UloopCapturedVariableScope.Local, "System.Int32", "5", string.Empty, string.Empty, 0) + new("speed", UloopCapturedVariableScope.Local, "System.Int32", "5", string.Empty, string.Empty, 0, false) }; UloopPausePointRegistry.HitWithCapturedVariables("jump", capturedVariables, true); JObject parameters = new() { ["id"] = "jump" }; @@ -1004,7 +1004,7 @@ public void HitWithCapturedVariables_WhenPausePointIsEnabled_StoresCapturedVaria UloopPausePointRegistry.Enable("jump", 30); UloopCapturedVariable[] capturedVariables = { - new("speed", UloopCapturedVariableScope.Local, "System.Int32", "5", string.Empty, string.Empty, 0) + new("speed", UloopCapturedVariableScope.Local, "System.Int32", "5", string.Empty, string.Empty, 0, false) }; UloopPausePointSnapshot snapshot = UloopPausePointRegistry.HitWithCapturedVariables( @@ -1027,10 +1027,12 @@ public void TryGetCapturedValue_WhenLatestHitStoredRawFrame_ReturnsLiveReference new UloopPausePointCapturedVariableEntry("scores", UloopCapturedVariableScope.Local, scores), new UloopPausePointCapturedVariableEntry("empty", UloopCapturedVariableScope.Local, null) }, - false); + false, + System.Array.Empty(), + 0); UloopCapturedVariable[] capturedVariables = { - new("scores", UloopCapturedVariableScope.Local, "System.Collections.Generic.List`1[System.Int32]", "[10,20,30]", string.Empty, string.Empty, 0) + new("scores", UloopCapturedVariableScope.Local, "System.Collections.Generic.List`1[System.Int32]", "[10,20,30]", string.Empty, string.Empty, 0, false) }; UloopPausePointRegistry.HitWithCapturedFrame("jump", frame, capturedVariables, false); @@ -1056,7 +1058,9 @@ public void TryGetCapturedValue_WhenRegistryClearsLatestHit_ReturnsNotFound() UloopPausePointRegistry.Enable("jump", 30); UloopPausePointCapturedVariableFrame frame = new( new[] { new UloopPausePointCapturedVariableEntry("speed", UloopCapturedVariableScope.Local, 5) }, - false); + false, + System.Array.Empty(), + 0); UloopPausePointRegistry.HitWithCapturedFrame( "jump", frame, Array.Empty(), false); @@ -1076,10 +1080,14 @@ public void TryGetCapturedValue_WhenNewHitReplacesPrevious_ExposesLatestSnapshot UloopPausePointRegistry.Enable("land", 30); UloopPausePointCapturedVariableFrame jumpFrame = new( new[] { new UloopPausePointCapturedVariableEntry("speed", UloopCapturedVariableScope.Local, 1) }, - false); + false, + System.Array.Empty(), + 0); UloopPausePointCapturedVariableFrame landFrame = new( new[] { new UloopPausePointCapturedVariableEntry("speed", UloopCapturedVariableScope.Local, 2) }, - false); + false, + System.Array.Empty(), + 0); UloopPausePointRegistry.HitWithCapturedFrame("jump", jumpFrame, Array.Empty(), false); UloopPausePointRegistry.HitWithCapturedFrame("land", landFrame, Array.Empty(), false); @@ -1098,7 +1106,9 @@ public void TryGetCapturedValue_WhenUnrelatedPausePointIsCleared_KeepsLatestHitR UloopPausePointRegistry.Enable("land", 30); UloopPausePointCapturedVariableFrame landFrame = new( new[] { new UloopPausePointCapturedVariableEntry("speed", UloopCapturedVariableScope.Local, 7) }, - false); + false, + System.Array.Empty(), + 0); UloopPausePointRegistry.HitWithCapturedFrame("land", landFrame, Array.Empty(), false); UloopPausePointRegistry.Clear("jump"); @@ -1117,7 +1127,9 @@ public void TryGetCapturedValue_WhenSamePausePointIsReenabledWhilePaused_KeepsLa UloopPausePointRegistry.Enable("jump", 30); UloopPausePointCapturedVariableFrame frame = new( new[] { new UloopPausePointCapturedVariableEntry("speed", UloopCapturedVariableScope.Local, 1) }, - false); + false, + System.Array.Empty(), + 0); UloopPausePointRegistry.HitWithCapturedFrame("jump", frame, Array.Empty(), false); UloopPausePointRegistry.Enable("jump", 30); @@ -1136,7 +1148,9 @@ public void TryGetCapturedValue_WhenSamePausePointIsReenabledThenCleared_StillCl UloopPausePointRegistry.Enable("jump", 30); UloopPausePointCapturedVariableFrame frame = new( new[] { new UloopPausePointCapturedVariableEntry("speed", UloopCapturedVariableScope.Local, 1) }, - false); + false, + System.Array.Empty(), + 0); UloopPausePointRegistry.HitWithCapturedFrame("jump", frame, Array.Empty(), false); UloopPausePointRegistry.Enable("jump", 30); diff --git a/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointCaptureTests.cs b/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointCaptureTests.cs index e027e736d8..2198fe0588 100644 --- a/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointCaptureTests.cs +++ b/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointCaptureTests.cs @@ -221,8 +221,8 @@ public void Collect_WithNullInstance_AddsNoThisEntry() [Test] public void Collect_WhenCountCapReachedBeforeThis_OmitsThisAndReportsTruncated() { - // Verifies that when locals already fill the count cap, the "this" entry is dropped and - // truncation is reported per the existing TryAppendEntry contract. + // Verifies that when locals already fill the count cap, the "this" entry is dropped from + // Entries but still counted in TruncatedVariableNames / TruncatedVariableCount. int localCount = SourcePausePointConstants.MaxCapturedVariableCount; object[] locals = new object[localCount * 2]; for (int i = 0; i < localCount; i++) @@ -239,6 +239,45 @@ public void Collect_WhenCountCapReachedBeforeThis_OmitsThisAndReportsTruncated() Assert.That(frame.Entries.Count, Is.EqualTo(SourcePausePointConstants.MaxCapturedVariableCount)); Assert.That(frame.Entries.Any(entry => entry.Name == "this"), Is.False); Assert.That(frame.Truncated, Is.True); + Assert.That(frame.TruncatedVariableCount, Is.GreaterThan(0)); + Assert.That(frame.TruncatedVariableNames, Does.Contain("this")); + } + + [Test] + public void Collect_WhenVariableCountExceedsCap_ReportsTruncatedNamesUpToLimitAndExactCount() + { + // Verifies count-cap overflow keeps collecting names (capped at 20) with an exact total. + int discarded = SourcePausePointConstants.MaxTruncatedVariableNamesReported + 5; + int localCount = SourcePausePointConstants.MaxCapturedVariableCount + discarded; + object[] locals = new object[localCount * 2]; + for (int i = 0; i < localCount; i++) + { + locals[i * 2] = $"local{i}"; + locals[i * 2 + 1] = i; + } + + UloopPausePointCapturedVariableFrame frame = SourcePausePointVariableCollector.Collect( + null, Array.Empty(), locals); + + Assert.That(frame.Entries.Count, Is.EqualTo(SourcePausePointConstants.MaxCapturedVariableCount)); + Assert.That(frame.Truncated, Is.True); + Assert.That(frame.TruncatedVariableCount, Is.EqualTo(discarded)); + Assert.That(frame.TruncatedVariableNames.Count, Is.EqualTo(SourcePausePointConstants.MaxTruncatedVariableNamesReported)); + Assert.That(frame.TruncatedVariableNames[0], Is.EqualTo($"local{SourcePausePointConstants.MaxCapturedVariableCount}")); + } + + [Test] + public void Collect_WhenUnderCountCap_ReportsEmptyTruncatedNames() + { + // Verifies no truncation metadata when every variable fits under the count cap. + object[] locals = { "speed", 5, "damage", 3 }; + + UloopPausePointCapturedVariableFrame frame = SourcePausePointVariableCollector.Collect( + null, Array.Empty(), locals); + + Assert.That(frame.Truncated, Is.False); + Assert.That(frame.TruncatedVariableCount, Is.EqualTo(0)); + Assert.That(frame.TruncatedVariableNames, Is.Empty); } [Test] diff --git a/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointVariableFormatterTests.cs b/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointVariableFormatterTests.cs index 197fdf3256..af3c2c7233 100644 --- a/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointVariableFormatterTests.cs +++ b/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointVariableFormatterTests.cs @@ -100,6 +100,7 @@ public void Format_WhenValueExceedsMaxLength_TruncatesValueAndSetsTruncatedFlag( Assert.That(variables.Single().Value.Length, Is.EqualTo(SourcePausePointConstants.MaxCapturedVariableValueLength)); Assert.That(truncated, Is.True); + Assert.That(variables.Single().Truncated, Is.True); } [Test] diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileSchema.cs b/Packages/src/Editor/FirstPartyTools/Compile/CompileSchema.cs index 6f153a042b..7d8338a0c5 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/CompileSchema.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileSchema.cs @@ -28,8 +28,9 @@ public class CompileSchema : UnityCliLoopToolSchema /// /// How long the CLI waits for compilation to complete, in seconds. /// Unity ignores this value; it is consumed by the CLI. + /// Why no [Description]: first-party schema properties must keep long-form agent guidance + /// in skill files (see FirstPartyToolSchemaMetadataTests), not runtime metadata. /// - [Description("How long the CLI waits for compilation to complete, in seconds. Unity ignores this value; it is consumed by the CLI.")] public int CompileWaitTimeoutSeconds { get; set; } = 600; /// diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs index f782e945bf..38cb3a206a 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs @@ -192,6 +192,7 @@ public class PausePointCapturedVariable public string UnityObjectKind { get; set; } = string.Empty; public string UnityObjectPath { get; set; } = string.Empty; public int UnityObjectInstanceId { get; set; } + public bool Truncated { get; set; } internal static PausePointCapturedVariable FromSnapshot(UloopCapturedVariable snapshot) { @@ -208,7 +209,8 @@ internal static PausePointCapturedVariable FromSnapshot(UloopCapturedVariable sn Value = snapshot.Value, UnityObjectKind = snapshot.UnityObjectKind, UnityObjectPath = snapshot.UnityObjectPath, - UnityObjectInstanceId = snapshot.UnityObjectInstanceId + UnityObjectInstanceId = snapshot.UnityObjectInstanceId, + Truncated = snapshot.Truncated }; } } diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs index e7f085af4e..f73a767407 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs @@ -22,6 +22,9 @@ internal static class SourcePausePointConstants // Keeps a single hit's payload small enough for the CLI response and for the console-like // pause-point evidence to stay skimmable, mirroring the truncation-by-cap pattern MatchingLogs uses. public const int MaxCapturedVariableCount = 50; + // How many discarded variable names to surface when the count cap drops extras. The exact + // discarded count is still reported in full via TruncatedVariableCount. + public const int MaxTruncatedVariableNamesReported = 20; public const int MaxCapturedVariableValueLength = 256; // Mirrors UloopPausePointRegistry.DefaultMaxPreviewElements (the Runtime-owned per-marker // default enforced at Enable time) instead of a second independent literal, so the two diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointVariableCollector.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointVariableCollector.cs index ca78ad5703..010b863f2e 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointVariableCollector.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointVariableCollector.cs @@ -31,44 +31,56 @@ public static UloopPausePointCapturedVariableFrame Collect( Debug.Assert(localNamesAndValues.Length % 2 == 0, "localNamesAndValues must contain name/value pairs"); List entries = new(); + List truncatedVariableNames = new(); + int truncatedVariableCount = 0; bool truncated = false; HashSet capturedNames = new(); - bool countCapReached = AppendPairs( - entries, capturedNames, ref truncated, localNamesAndValues, UloopCapturedVariableScope.Local); - if (!countCapReached) - { - countCapReached = AppendPairs( - entries, capturedNames, ref truncated, parameterNamesAndValues, UloopCapturedVariableScope.Parameter); - } + // Why keep scanning after the count cap: callers need the discarded names and the exact + // dropped count, not only a Truncated bool. Values past the cap are never retained. + AppendPairs( + entries, capturedNames, truncatedVariableNames, ref truncatedVariableCount, ref truncated, + localNamesAndValues, UloopCapturedVariableScope.Local); + AppendPairs( + entries, capturedNames, truncatedVariableNames, ref truncatedVariableCount, ref truncated, + parameterNamesAndValues, UloopCapturedVariableScope.Parameter); - if (instance != null && !countCapReached) + if (instance != null) { - CollectInstanceFieldVariables(instance, entries, capturedNames, ref truncated); + CollectInstanceFieldVariables( + instance, entries, capturedNames, truncatedVariableNames, ref truncatedVariableCount, + ref truncated); } - return new UloopPausePointCapturedVariableFrame(entries, truncated); + return new UloopPausePointCapturedVariableFrame( + entries, truncated, truncatedVariableNames, truncatedVariableCount); } - private static bool AppendPairs( - List entries, HashSet capturedNames, ref bool truncated, - object[] namesAndValues, string scope) + private static void AppendPairs( + List entries, + HashSet capturedNames, + List truncatedVariableNames, + ref int truncatedVariableCount, + ref bool truncated, + object[] namesAndValues, + string scope) { for (int i = 0; i < namesAndValues.Length; i += 2) { string name = (string)namesAndValues[i]; object value = namesAndValues[i + 1]; - if (!TryAppendEntry(entries, capturedNames, ref truncated, name, scope, value)) - { - return true; - } + TryAppendEntry( + entries, capturedNames, truncatedVariableNames, ref truncatedVariableCount, ref truncated, + name, scope, value); } - - return false; } private static void CollectInstanceFieldVariables( - object instance, List entries, HashSet capturedNames, + object instance, + List entries, + HashSet capturedNames, + List truncatedVariableNames, + ref int truncatedVariableCount, ref bool truncated) { bool isCompilerGeneratedStateMachine = @@ -78,34 +90,38 @@ private static void CollectInstanceFieldVariables( // count cap keeps prioritizing locals and parameters over instance state. if (!isCompilerGeneratedStateMachine) { - if (!TryAppendEntry( - entries, capturedNames, ref truncated, ThisEntryName, UloopCapturedVariableScope.This, instance)) - { - return; - } + TryAppendEntry( + entries, capturedNames, truncatedVariableNames, ref truncatedVariableCount, ref truncated, + ThisEntryName, UloopCapturedVariableScope.This, instance); } - (object outerThis, bool countCapReached) = CollectDirectFieldVariables( - instance, entries, capturedNames, ref truncated, followOuterThis: true); - if (countCapReached || outerThis == null) + object outerThis = CollectDirectFieldVariables( + instance, entries, capturedNames, truncatedVariableNames, ref truncatedVariableCount, + ref truncated, followOuterThis: true); + if (outerThis == null) { return; } // Async/coroutine state machine: the real `this` is the hoisted outer instance, never the // compiler-generated state machine object. Emit it before the outer instance's fields. - if (!TryAppendEntry( - entries, capturedNames, ref truncated, ThisEntryName, UloopCapturedVariableScope.This, outerThis)) - { - return; - } + TryAppendEntry( + entries, capturedNames, truncatedVariableNames, ref truncatedVariableCount, ref truncated, + ThisEntryName, UloopCapturedVariableScope.This, outerThis); - CollectDirectFieldVariables(outerThis, entries, capturedNames, ref truncated, followOuterThis: false); + CollectDirectFieldVariables( + outerThis, entries, capturedNames, truncatedVariableNames, ref truncatedVariableCount, + ref truncated, followOuterThis: false); } - private static (object OuterThis, bool CountCapReached) CollectDirectFieldVariables( - object source, List entries, HashSet capturedNames, - ref bool truncated, bool followOuterThis) + private static object CollectDirectFieldVariables( + object source, + List entries, + HashSet capturedNames, + List truncatedVariableNames, + ref int truncatedVariableCount, + ref bool truncated, + bool followOuterThis) { object outerThis = null; bool isCompilerGeneratedStateMachine = Attribute.IsDefined(source.GetType(), typeof(CompilerGeneratedAttribute)); @@ -124,13 +140,9 @@ private static (object OuterThis, bool CountCapReached) CollectDirectFieldVariab Match hoistedLocalMatch = HoistedLocalFieldNamePattern.Match(field.Name); if (hoistedLocalMatch.Success) { - if (!TryAppendEntry( - entries, capturedNames, ref truncated, hoistedLocalMatch.Groups[1].Value, - UloopCapturedVariableScope.Local, field.GetValue(source))) - { - return (outerThis, true); - } - + TryAppendEntry( + entries, capturedNames, truncatedVariableNames, ref truncatedVariableCount, ref truncated, + hoistedLocalMatch.Groups[1].Value, UloopCapturedVariableScope.Local, field.GetValue(source)); continue; } @@ -142,13 +154,12 @@ private static (object OuterThis, bool CountCapReached) CollectDirectFieldVariab continue; } - if (!TryAppendEntry(entries, capturedNames, ref truncated, fieldName, plainFieldScope, field.GetValue(source))) - { - return (outerThis, true); - } + TryAppendEntry( + entries, capturedNames, truncatedVariableNames, ref truncatedVariableCount, ref truncated, + fieldName, plainFieldScope, field.GetValue(source)); } - return (outerThis, false); + return outerThis; } private static IEnumerable EnumerateInstanceFields(Type type) @@ -167,23 +178,34 @@ private static IEnumerable EnumerateInstanceFields(Type type) } } - private static bool TryAppendEntry( - List entries, HashSet capturedNames, ref bool truncated, - string name, string scope, object rawValue) + private static void TryAppendEntry( + List entries, + HashSet capturedNames, + List truncatedVariableNames, + ref int truncatedVariableCount, + ref bool truncated, + string name, + string scope, + object rawValue) { if (!capturedNames.Add(name)) { - return true; + return; } if (entries.Count >= SourcePausePointConstants.MaxCapturedVariableCount) { truncated = true; - return false; + truncatedVariableCount++; + if (truncatedVariableNames.Count < SourcePausePointConstants.MaxTruncatedVariableNamesReported) + { + truncatedVariableNames.Add(name); + } + + return; } entries.Add(new UloopPausePointCapturedVariableEntry(name, scope, rawValue)); - return true; } } } diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointVariableFormatter.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointVariableFormatter.cs index e50bdc0dbe..e336b200de 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointVariableFormatter.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointVariableFormatter.cs @@ -38,18 +38,22 @@ public static (List Variables, bool Truncated) FormatFram bool truncated = frame.Truncated; foreach (UloopPausePointCapturedVariableEntry entry in frame.Entries) { - results.Add(FormatVariable(entry.Name, entry.Scope, entry.Value, maxCollectionPreviewElementCount, ref truncated)); + UloopCapturedVariable variable = FormatVariable( + entry.Name, entry.Scope, entry.Value, maxCollectionPreviewElementCount); + results.Add(variable); + truncated |= variable.Truncated; } return (results, truncated); } private static UloopCapturedVariable FormatVariable( - string name, string scope, object rawValue, int maxCollectionPreviewElementCount, ref bool truncated) + string name, string scope, object rawValue, int maxCollectionPreviewElementCount) { if (rawValue == null) { - return new UloopCapturedVariable(name, scope, string.Empty, "null", string.Empty, string.Empty, 0); + return new UloopCapturedVariable( + name, scope, string.Empty, "null", string.Empty, string.Empty, 0, truncated: false); } string typeName = rawValue.GetType().FullName; @@ -58,8 +62,11 @@ private static UloopCapturedVariable FormatVariable( return FormatUnityObjectVariable(name, scope, typeName, unityObjectCandidate); } + // Why a per-variable flag: overall CapturedVariablesTruncated alone cannot tell which + // value was clipped after a name filter narrows the list. + bool variableTruncated = false; if (SourcePausePointCollectionPreviewSerializer.TrySerialize( - rawValue, maxCollectionPreviewElementCount, ref truncated, out string collectionPreview)) + rawValue, maxCollectionPreviewElementCount, ref variableTruncated, out string collectionPreview)) { // Why scale: a per-marker element-count override that raises the element cap // without also raising the byte budget would still get clipped by the fixed @@ -69,13 +76,15 @@ private static UloopCapturedVariable FormatVariable( // the default (10 elements, 1024 chars) already implies. int scaledValueLengthCap = SourcePausePointConstants.MaxCollectionPreviewValueLength * maxCollectionPreviewElementCount / UloopPausePointRegistry.DefaultMaxPreviewElements; - string cappedPreview = ApplyValueLengthCap(collectionPreview, scaledValueLengthCap, ref truncated); - return new UloopCapturedVariable(name, scope, typeName, cappedPreview, string.Empty, string.Empty, 0); + string cappedPreview = ApplyValueLengthCap(collectionPreview, scaledValueLengthCap, ref variableTruncated); + return new UloopCapturedVariable( + name, scope, typeName, cappedPreview, string.Empty, string.Empty, 0, variableTruncated); } string value = ApplyValueLengthCap( - SafeToString(rawValue), SourcePausePointConstants.MaxCapturedVariableValueLength, ref truncated); - return new UloopCapturedVariable(name, scope, typeName, value, string.Empty, string.Empty, 0); + SafeToString(rawValue), SourcePausePointConstants.MaxCapturedVariableValueLength, ref variableTruncated); + return new UloopCapturedVariable( + name, scope, typeName, value, string.Empty, string.Empty, 0, variableTruncated); } private static UloopCapturedVariable FormatUnityObjectVariable( @@ -83,21 +92,23 @@ private static UloopCapturedVariable FormatUnityObjectVariable( { if (!MainThreadSwitcher.IsMainThread) { - return new UloopCapturedVariable(name, scope, typeName, OffMainThreadValue, string.Empty, string.Empty, 0); + return new UloopCapturedVariable( + name, scope, typeName, OffMainThreadValue, string.Empty, string.Empty, 0, truncated: false); } if (unityObjectCandidate == null) { return new UloopCapturedVariable( name, scope, typeName, DestroyedValue, - UloopCapturedVariableUnityObjectKind.Destroyed, string.Empty, UnityObjectIdentifier.GetInstanceId(unityObjectCandidate)); + UloopCapturedVariableUnityObjectKind.Destroyed, string.Empty, + UnityObjectIdentifier.GetInstanceId(unityObjectCandidate), truncated: false); } SourcePausePointUnityObjectClassifier.Classification classification = SourcePausePointUnityObjectClassifier.Classify(unityObjectCandidate); return new UloopCapturedVariable( name, scope, typeName, unityObjectCandidate.name, - classification.Kind, classification.Path, classification.InstanceId); + classification.Kind, classification.Path, classification.InstanceId, truncated: false); } private static string ApplyValueLengthCap(string value, int maxLength, ref bool truncated) diff --git a/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs b/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs index 298fff0679..e637ab3a85 100644 --- a/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs +++ b/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs @@ -132,6 +132,8 @@ public class PausePointStatusResponse : UnityCliLoopToolResponse public IReadOnlyList CapturedVariables { get; set; } = Array.Empty(); public bool CapturedVariablesTruncated { get; set; } + public IReadOnlyList TruncatedVariableNames { get; set; } = Array.Empty(); + public int TruncatedVariableCount { get; set; } public string ClearedReason { get; set; } = string.Empty; public string StatusBeforeClear { get; set; } = string.Empty; public bool LateHitDiscardedAfterClear { get; set; } @@ -174,6 +176,8 @@ internal static PausePointStatusResponse FromSnapshot(UloopPausePointSnapshot sn .Select(PausePointStatusCapturedVariable.FromCapturedVariable) .ToList(), CapturedVariablesTruncated = snapshot.CapturedVariablesTruncated, + TruncatedVariableNames = snapshot.TruncatedVariableNames, + TruncatedVariableCount = snapshot.TruncatedVariableCount, ClearedReason = snapshot.ClearedReason, StatusBeforeClear = snapshot.StatusBeforeClear, LateHitDiscardedAfterClear = snapshot.LateHitDiscardedAfterClear @@ -252,6 +256,7 @@ public class PausePointStatusCapturedVariable public string UnityObjectKind { get; set; } = string.Empty; public string UnityObjectPath { get; set; } = string.Empty; public int UnityObjectInstanceId { get; set; } + public bool Truncated { get; set; } internal static PausePointStatusCapturedVariable FromCapturedVariable(UloopCapturedVariable capturedVariable) { @@ -268,7 +273,8 @@ internal static PausePointStatusCapturedVariable FromCapturedVariable(UloopCaptu Value = capturedVariable.Value, UnityObjectKind = capturedVariable.UnityObjectKind, UnityObjectPath = capturedVariable.UnityObjectPath, - UnityObjectInstanceId = capturedVariable.UnityObjectInstanceId + UnityObjectInstanceId = capturedVariable.UnityObjectInstanceId, + Truncated = capturedVariable.Truncated }; } } diff --git a/Packages/src/Runtime/PausePoints/UloopCapturedVariable.cs b/Packages/src/Runtime/PausePoints/UloopCapturedVariable.cs index 65df549f34..1e9819c167 100644 --- a/Packages/src/Runtime/PausePoints/UloopCapturedVariable.cs +++ b/Packages/src/Runtime/PausePoints/UloopCapturedVariable.cs @@ -17,7 +17,8 @@ public UloopCapturedVariable( string value, string unityObjectKind, string unityObjectPath, - int unityObjectInstanceId) + int unityObjectInstanceId, + bool truncated) { Debug.Assert(!string.IsNullOrEmpty(name), "name must not be null or empty"); Debug.Assert(!string.IsNullOrEmpty(scope), "scope must not be null or empty"); @@ -29,6 +30,7 @@ public UloopCapturedVariable( UnityObjectKind = unityObjectKind ?? string.Empty; UnityObjectPath = unityObjectPath ?? string.Empty; UnityObjectInstanceId = unityObjectInstanceId; + Truncated = truncated; } public string Name { get; } @@ -38,6 +40,10 @@ public UloopCapturedVariable( public string UnityObjectKind { get; } public string UnityObjectPath { get; } public int UnityObjectInstanceId { get; } + + // True when this variable's value/preview was clipped (length, collection elements, or + // preview depth). Distinct from the response-level CapturedVariablesTruncated OR. + public bool Truncated { get; } } } #endif diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointCapturedVariableFrame.cs b/Packages/src/Runtime/PausePoints/UloopPausePointCapturedVariableFrame.cs index 559d9d80c7..a90333b16c 100644 --- a/Packages/src/Runtime/PausePoints/UloopPausePointCapturedVariableFrame.cs +++ b/Packages/src/Runtime/PausePoints/UloopPausePointCapturedVariableFrame.cs @@ -1,4 +1,5 @@ #if UNITY_EDITOR +using System; using System.Collections.Generic; namespace io.github.hatayama.UnityCliLoop.Runtime @@ -9,14 +10,25 @@ namespace io.github.hatayama.UnityCliLoop.Runtime internal sealed class UloopPausePointCapturedVariableFrame { public UloopPausePointCapturedVariableFrame( - IReadOnlyList entries, bool truncated) + IReadOnlyList entries, + bool truncated, + IReadOnlyList truncatedVariableNames, + int truncatedVariableCount) { Entries = entries; Truncated = truncated; + TruncatedVariableNames = truncatedVariableNames ?? Array.Empty(); + TruncatedVariableCount = truncatedVariableCount; } public IReadOnlyList Entries { get; } public bool Truncated { get; } + + // Names dropped by the variable-count cap (at most MaxTruncatedVariableNamesReported). + public IReadOnlyList TruncatedVariableNames { get; } + + // Exact number of variables dropped by the count cap (not capped at the names list length). + public int TruncatedVariableCount { get; } } } #endif diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointEntry.cs b/Packages/src/Runtime/PausePoints/UloopPausePointEntry.cs index b9efccf304..2beef43ed8 100644 --- a/Packages/src/Runtime/PausePoints/UloopPausePointEntry.cs +++ b/Packages/src/Runtime/PausePoints/UloopPausePointEntry.cs @@ -31,6 +31,7 @@ public UloopPausePointEntry( IsEnabled = true; Message = "Pause point enabled."; CapturedVariables = Array.Empty(); + TruncatedVariableNames = Array.Empty(); _capturedVariableHistory = new Queue(maxHistory); } @@ -54,6 +55,8 @@ public UloopPausePointEntry( public string Message { get; private set; } public IReadOnlyList CapturedVariables { get; private set; } public bool CapturedVariablesTruncated { get; private set; } + public IReadOnlyList TruncatedVariableNames { get; private set; } + public int TruncatedVariableCount { get; private set; } public int HistoryDroppedCount { get; private set; } public string ClearedReason { get; private set; } = string.Empty; public string StatusBeforeClear { get; private set; } = string.Empty; @@ -169,10 +172,14 @@ public void RecordHitWithCapturedVariables( int hitSequence, int frameCount, IReadOnlyList capturedVariables, - bool capturedVariablesTruncated) + bool capturedVariablesTruncated, + IReadOnlyList truncatedVariableNames, + int truncatedVariableCount) { Debug.Assert(hitSequence > 0, "hitSequence must be greater than zero"); Debug.Assert(capturedVariables != null, "capturedVariables must not be null"); + Debug.Assert(truncatedVariableNames != null, "truncatedVariableNames must not be null"); + Debug.Assert(truncatedVariableCount >= 0, "truncatedVariableCount must not be negative"); if (HitCount == 0) { @@ -192,6 +199,8 @@ public void RecordHitWithCapturedVariables( : "Pause point hit; Unity pause was requested."; CapturedVariables = capturedVariables; CapturedVariablesTruncated = capturedVariablesTruncated; + TruncatedVariableNames = truncatedVariableNames; + TruncatedVariableCount = truncatedVariableCount; if (_capturedVariableHistory.Count == MaxHistory) { @@ -254,6 +263,8 @@ public UloopPausePointSnapshot ToSnapshot(DateTime nowUtc, IUloopPausePointPause recommendedNextAction, CapturedVariables, CapturedVariablesTruncated, + TruncatedVariableNames, + TruncatedVariableCount, ClearedReason, StatusBeforeClear, LateHitDiscardedAfterClear); diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs index d822d3af1a..6e9b826c58 100644 --- a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs +++ b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs @@ -326,9 +326,16 @@ private static UloopPausePointSnapshot HitCore( int hitSequence = ++_nextHitSequence; int frameCount = Time.frameCount; + IReadOnlyList truncatedVariableNames = capturedFrame != null + ? capturedFrame.TruncatedVariableNames + : Array.Empty(); + int truncatedVariableCount = capturedFrame != null + ? capturedFrame.TruncatedVariableCount + : 0; entry.RecordHitWithCapturedVariables( now, _pauseController.IsPlaying, _pauseController.IsPaused, hitSequence, - frameCount, capturedVariables, capturedVariablesTruncated); + frameCount, capturedVariables, capturedVariablesTruncated, + truncatedVariableNames, truncatedVariableCount); UloopPausePointSnapshot snapshot = entry.ToSnapshot(now, _pauseController); _latestHitSnapshot = snapshot; _hitSnapshots.RemoveAll(hitSnapshot => hitSnapshot.Id == id); diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointSnapshot.cs b/Packages/src/Runtime/PausePoints/UloopPausePointSnapshot.cs index e23a4280e9..cee601780e 100644 --- a/Packages/src/Runtime/PausePoints/UloopPausePointSnapshot.cs +++ b/Packages/src/Runtime/PausePoints/UloopPausePointSnapshot.cs @@ -37,6 +37,8 @@ public UloopPausePointSnapshot( string recommendedNextAction, IReadOnlyList capturedVariables, bool capturedVariablesTruncated, + IReadOnlyList truncatedVariableNames, + int truncatedVariableCount, string clearedReason, string statusBeforeClear, bool lateHitDiscardedAfterClear) @@ -68,6 +70,8 @@ public UloopPausePointSnapshot( RecommendedNextAction = recommendedNextAction ?? string.Empty; CapturedVariables = capturedVariables ?? Array.Empty(); CapturedVariablesTruncated = capturedVariablesTruncated; + TruncatedVariableNames = truncatedVariableNames ?? Array.Empty(); + TruncatedVariableCount = truncatedVariableCount; ClearedReason = clearedReason ?? string.Empty; StatusBeforeClear = statusBeforeClear ?? string.Empty; LateHitDiscardedAfterClear = lateHitDiscardedAfterClear; @@ -98,6 +102,8 @@ public UloopPausePointSnapshot( public string RecommendedNextAction { get; } public IReadOnlyList CapturedVariables { get; } public bool CapturedVariablesTruncated { get; } + public IReadOnlyList TruncatedVariableNames { get; } + public int TruncatedVariableCount { get; } public string ClearedReason { get; } public string StatusBeforeClear { get; } public bool LateHitDiscardedAfterClear { get; } @@ -134,6 +140,8 @@ public static UloopPausePointSnapshot NotEnabled(string id, IUloopPausePointPaus string.Empty, Array.Empty(), false, + Array.Empty(), + 0, string.Empty, string.Empty, false); diff --git a/cli/project-runner/internal/projectrunner/pause_point_captured_variable_names_filter.go b/cli/project-runner/internal/projectrunner/pause_point_captured_variable_names_filter.go index 52eaa599aa..a6ee22749c 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_captured_variable_names_filter.go +++ b/cli/project-runner/internal/projectrunner/pause_point_captured_variable_names_filter.go @@ -36,6 +36,11 @@ func filterPausePointCapturedVariablesByName( return response } + // Why before filtering: pause-point-status runs this filter without a hit gate. An unhit + // marker has empty CapturedVariables/history, which would otherwise look like a name miss + // and a Warning blaming the requested names would misdiagnose "not hit yet". + hadCapturedVariables := pausePointResponseHasCapturedVariables(response) + nameSet := make(map[string]struct{}, len(names)) for _, name := range names { nameSet[name] = struct{}{} @@ -60,9 +65,31 @@ func filterPausePointCapturedVariablesByName( response.CapturedVariableNameFilterNoMatch = totalMatchCount == 0 response.CapturedVariableNamesNotFound = unmatchedCapturedVariableNames(names, matchedNames) + // Why Warning only when hadCapturedVariables: machine-readable flags still fire on empty + // snapshots (unchanged), but a human Warning must not claim a name miss when the hit has + // not produced any variables yet. + if hadCapturedVariables && response.CapturedVariableNameFilterNoMatch { + response.Warning = joinPausePointWarnings( + response.Warning, + "No captured variable matched the requested names; the hit captured other variables. Check CapturedVariableNamesNotFound for the names that were absent.") + } return response } +// pausePointResponseHasCapturedVariables reports whether the snapshot already holds any +// captured variable (current or history) before a name filter runs. +func pausePointResponseHasCapturedVariables(response pausePointStatusResponse) bool { + if len(response.CapturedVariables) > 0 { + return true + } + for _, frame := range response.CapturedVariableHistory { + if len(frame.CapturedVariables) > 0 { + return true + } + } + return false +} + // unmatchedCapturedVariableNames lists the requested names that matched nothing, keeping the order // they were requested in so the report reads back against the flag value the caller wrote. A name // matched anywhere — current variables or any history frame — counts as found. A name requested diff --git a/cli/project-runner/internal/projectrunner/pause_point_captured_variable_names_filter_test.go b/cli/project-runner/internal/projectrunner/pause_point_captured_variable_names_filter_test.go index f1ea9cd846..5bddb89fee 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_captured_variable_names_filter_test.go +++ b/cli/project-runner/internal/projectrunner/pause_point_captured_variable_names_filter_test.go @@ -63,6 +63,21 @@ func TestFilterPausePointCapturedVariablesByName(t *testing.T) { if !result.CapturedVariableNameFilterNoMatch { t.Fatal("expected CapturedVariableNameFilterNoMatch to be true when nothing matches") } + const wantWarning = "No captured variable matched the requested names; the hit captured other variables. Check CapturedVariableNamesNotFound for the names that were absent." + if result.Warning != wantWarning { + t.Fatalf("expected human-readable Warning for no-match filter: %q", result.Warning) + } + }) + + t.Run("empty pre-filter snapshot keeps no-match flag but skips Warning", func(t *testing.T) { + // Verifies an unhit status (no variables yet) does not blame the requested names. + result := filterPausePointCapturedVariablesByName(pausePointStatusResponse{}, []string{"velocity"}) + if !result.CapturedVariableNameFilterNoMatch { + t.Fatal("expected CapturedVariableNameFilterNoMatch to stay true on an empty snapshot") + } + if result.Warning != "" { + t.Fatalf("expected no Warning when the snapshot had no variables before filtering: %q", result.Warning) + } }) t.Run("composes with captured-variables names mode: filter first, then strip values", func(t *testing.T) { diff --git a/cli/project-runner/internal/projectrunner/pause_point_types.go b/cli/project-runner/internal/projectrunner/pause_point_types.go index 6a96147c63..7ad851b594 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_types.go +++ b/cli/project-runner/internal/projectrunner/pause_point_types.go @@ -27,6 +27,8 @@ type pausePointStatusResponse struct { RecommendedNextAction string `json:"RecommendedNextAction"` CapturedVariables []pausePointCapturedVariable `json:"CapturedVariables"` CapturedVariablesTruncated bool `json:"CapturedVariablesTruncated"` + TruncatedVariableNames []string `json:"TruncatedVariableNames"` + TruncatedVariableCount int `json:"TruncatedVariableCount"` ClearedReason string `json:"ClearedReason"` StatusBeforeClear string `json:"StatusBeforeClear"` LateHitDiscardedAfterClear bool `json:"LateHitDiscardedAfterClear"` @@ -126,6 +128,7 @@ type pausePointCapturedVariable struct { UnityObjectKind string `json:"UnityObjectKind,omitempty"` UnityObjectPath string `json:"UnityObjectPath,omitempty"` UnityObjectInstanceId int `json:"UnityObjectInstanceId,omitempty"` + Truncated bool `json:"Truncated"` } // pausePointVariableValue returns a pointer to value for use in pausePointCapturedVariable diff --git a/tests/contracts/pause_point_status_response_contract.json b/tests/contracts/pause_point_status_response_contract.json index e5b903f286..f0e9d10801 100644 --- a/tests/contracts/pause_point_status_response_contract.json +++ b/tests/contracts/pause_point_status_response_contract.json @@ -43,10 +43,15 @@ "Value": "Enemy", "UnityObjectKind": "SceneObject", "UnityObjectPath": "MainScene:/Root/Enemy", - "UnityObjectInstanceId": -1234 + "UnityObjectInstanceId": -1234, + "Truncated": false } ], "CapturedVariablesTruncated": true, + "TruncatedVariableNames": [ + "extraField" + ], + "TruncatedVariableCount": 1, "ClearedReason": "", "StatusBeforeClear": "", "LateHitDiscardedAfterClear": false