From 0279c7754c09dcfbc2dbfe488c3a82e43eb62441 Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 24 Aug 2026 01:23:50 +0900 Subject: [PATCH 1/2] Align pause-point truncation aggregates with per-variable clipping Preview-clipped values used to set CapturedVariablesTruncated without counting those variables, so names and count could stay empty. Merge clipped entries with count-cap drops, and attach the CLI name-filter note from the unfiltered snapshot instead of Count==0. Co-authored-by: Cursor --- .../references/captured-variables.md | 1 + .../references/captured-variables.md | 1 + ...ourcePausePointTruncationAggregateTests.cs | 115 ++++++++++++++++++ ...PausePointTruncationAggregateTests.cs.meta | 11 ++ .../Skill/references/captured-variables.md | 1 + .../PausePoint/SourcePausePointCapture.cs | 3 +- .../SourcePausePointTruncationAggregate.cs | 70 +++++++++++ ...ourcePausePointTruncationAggregate.cs.meta | 11 ++ .../UloopPausePointCapturedVariableFrame.cs | 6 +- ...se_point_captured_variable_names_filter.go | 10 +- ...int_captured_variable_names_filter_test.go | 21 ++-- 11 files changed, 237 insertions(+), 13 deletions(-) create mode 100644 Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointTruncationAggregateTests.cs create mode 100644 Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointTruncationAggregateTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointTruncationAggregate.cs create mode 100644 Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointTruncationAggregate.cs.meta diff --git a/.agents/skills/uloop-pause-point/references/captured-variables.md b/.agents/skills/uloop-pause-point/references/captured-variables.md index e6887c23f..9b904b9ff 100644 --- a/.agents/skills/uloop-pause-point/references/captured-variables.md +++ b/.agents/skills/uloop-pause-point/references/captured-variables.md @@ -24,6 +24,7 @@ Read this before interpreting unexpected, missing, or truncated captured values, - A captured `Collision2D` is previewed as `{"Collider":{"Name":...,"UnityObjectPath":...},"OtherCollider":{...},"RelativeVelocity":...,"ContactCount":...}` — read `UnityObjectPath` to identify both colliding objects without an extra `execute-dynamic-code` round-trip. Each of `Collider` / `OtherCollider` is either that object form or the string `"(none)"` when the collider is null or destroyed. - A multidimensional array (`int[,]`, `int[,,]`, ...) previews as `{"Shape":"Int32[2,3]","TotalElements":6,"PreviewedElements":6,"ElementOrder":"row-major (last dimension fastest)","Elements":[...]}` instead of a bare JSON array, since `Elements` flattens every rank in row-major order (last dimension fastest) and would otherwise look like an empty or 1D collection; when `--max-preview-elements` cuts the list, the preview also includes `"ElementsTruncated":true` and a smaller `PreviewedElements`. A `T[]` or jagged `T[][]` array is unaffected and still previews as a plain JSON array. - `CapturedVariablesTruncated=true` means at least one value was clipped (value-length cap, collection preview element cap, or preview depth cap) or the variable-count cap stopped enumeration; clipped values are still present up to the cap. +- `TruncatedVariableCount` is the exact number of variables that were dropped whole by the variable-count cap or whose value preview was clipped (`Truncated: true` on the entry). `TruncatedVariableNames` lists that union in capture order, at most 20 names; the count stays exact when more than 20 were affected. The invariant is `CapturedVariablesTruncated == (TruncatedVariableCount > 0)` and `TruncatedVariableCount >= TruncatedVariableNames.Length`. ## Unity Object Values diff --git a/.claude/skills/uloop-pause-point/references/captured-variables.md b/.claude/skills/uloop-pause-point/references/captured-variables.md index e6887c23f..9b904b9ff 100644 --- a/.claude/skills/uloop-pause-point/references/captured-variables.md +++ b/.claude/skills/uloop-pause-point/references/captured-variables.md @@ -24,6 +24,7 @@ Read this before interpreting unexpected, missing, or truncated captured values, - A captured `Collision2D` is previewed as `{"Collider":{"Name":...,"UnityObjectPath":...},"OtherCollider":{...},"RelativeVelocity":...,"ContactCount":...}` — read `UnityObjectPath` to identify both colliding objects without an extra `execute-dynamic-code` round-trip. Each of `Collider` / `OtherCollider` is either that object form or the string `"(none)"` when the collider is null or destroyed. - A multidimensional array (`int[,]`, `int[,,]`, ...) previews as `{"Shape":"Int32[2,3]","TotalElements":6,"PreviewedElements":6,"ElementOrder":"row-major (last dimension fastest)","Elements":[...]}` instead of a bare JSON array, since `Elements` flattens every rank in row-major order (last dimension fastest) and would otherwise look like an empty or 1D collection; when `--max-preview-elements` cuts the list, the preview also includes `"ElementsTruncated":true` and a smaller `PreviewedElements`. A `T[]` or jagged `T[][]` array is unaffected and still previews as a plain JSON array. - `CapturedVariablesTruncated=true` means at least one value was clipped (value-length cap, collection preview element cap, or preview depth cap) or the variable-count cap stopped enumeration; clipped values are still present up to the cap. +- `TruncatedVariableCount` is the exact number of variables that were dropped whole by the variable-count cap or whose value preview was clipped (`Truncated: true` on the entry). `TruncatedVariableNames` lists that union in capture order, at most 20 names; the count stays exact when more than 20 were affected. The invariant is `CapturedVariablesTruncated == (TruncatedVariableCount > 0)` and `TruncatedVariableCount >= TruncatedVariableNames.Length`. ## Unity Object Values diff --git a/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointTruncationAggregateTests.cs b/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointTruncationAggregateTests.cs new file mode 100644 index 000000000..4e1c83007 --- /dev/null +++ b/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointTruncationAggregateTests.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.Runtime; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies count-cap drops and preview-clipped entries share one truncation aggregate. + /// + [TestFixture] + public sealed class SourcePausePointTruncationAggregateTests + { + /// + /// What: preview clipping alone puts that variable name on the aggregate with count 1. + /// + [Test] + public void CaptureFrame_WhenOnlyPreviewIsClipped_ReportsThatNameAndCountOne() + { + string longValue = new string('a', SourcePausePointConstants.MaxCapturedVariableValueLength + 10); + object[] locals = { "longText", longValue, "hp", 42 }; + + (UloopPausePointCapturedVariableFrame frame, List variables, bool truncated) = + SourcePausePointCapture.CaptureFrame(null, Array.Empty(), locals); + + Assert.That(truncated, Is.True); + Assert.That(frame.Truncated, Is.True); + Assert.That(frame.TruncatedVariableCount, Is.EqualTo(1)); + Assert.That(frame.TruncatedVariableNames, Is.EqualTo(new[] { "longText" })); + Assert.That(variables.Find(variable => variable.Name == "longText").Truncated, Is.True); + Assert.That(variables.Find(variable => variable.Name == "hp").Truncated, Is.False); + } + + /// + /// What: count-cap overflow alone keeps 20 reported names and the exact discarded count. + /// + [Test] + public void CaptureFrame_WhenOnlyCountCapDropsVariables_ReportsTwentyNamesAndExactCount() + { + int discarded = SourcePausePointConstants.MaxTruncatedVariableNamesReported + 5; + int localCount = SourcePausePointConstants.MaxCapturedVariableCount + discarded; + object[] locals = new object[localCount * 2]; + for (int index = 0; index < localCount; index++) + { + locals[index * 2] = $"local{index}"; + locals[index * 2 + 1] = index; + } + + (UloopPausePointCapturedVariableFrame frame, _, bool truncated) = + SourcePausePointCapture.CaptureFrame(null, Array.Empty(), locals); + + Assert.That(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}")); + } + + /// + /// What: preview clipping plus count-cap drops unions names in capture order. + /// + [Test] + public void CaptureFrame_WhenPreviewClipAndCountCapCombine_UnionsNamesInCaptureOrder() + { + int discarded = 3; + int localCount = SourcePausePointConstants.MaxCapturedVariableCount + discarded; + object[] locals = new object[localCount * 2]; + locals[0] = "longText"; + locals[1] = new string('a', SourcePausePointConstants.MaxCapturedVariableValueLength + 10); + for (int index = 1; index < localCount; index++) + { + locals[index * 2] = $"local{index}"; + locals[index * 2 + 1] = index; + } + + (UloopPausePointCapturedVariableFrame frame, _, bool truncated) = + SourcePausePointCapture.CaptureFrame(null, Array.Empty(), locals); + + Assert.That(truncated, Is.True); + Assert.That(frame.TruncatedVariableCount, Is.EqualTo(1 + discarded)); + Assert.That( + frame.TruncatedVariableNames, + Is.EqualTo(new[] + { + "longText", + $"local{SourcePausePointConstants.MaxCapturedVariableCount}", + $"local{SourcePausePointConstants.MaxCapturedVariableCount + 1}", + $"local{SourcePausePointConstants.MaxCapturedVariableCount + 2}" + })); + } + + /// + /// What: no clipping and no count-cap drop leaves the aggregate empty. + /// + [Test] + public void CaptureFrame_WhenNothingIsTruncated_ReportsEmptyAggregate() + { + object[] locals = { "speed", 5, "damage", 3 }; + + (UloopPausePointCapturedVariableFrame frame, _, bool truncated) = + SourcePausePointCapture.CaptureFrame(null, Array.Empty(), locals); + + Assert.That(truncated, Is.False); + Assert.That(frame.Truncated, Is.False); + Assert.That(frame.TruncatedVariableCount, Is.EqualTo(0)); + Assert.That(frame.TruncatedVariableNames, Is.Empty); + } + } +} diff --git a/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointTruncationAggregateTests.cs.meta b/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointTruncationAggregateTests.cs.meta new file mode 100644 index 000000000..35822c62e --- /dev/null +++ b/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointTruncationAggregateTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7fb1db5152148499d8c95d68be2d74a0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/references/captured-variables.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/references/captured-variables.md index e6887c23f..9b904b9ff 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/references/captured-variables.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/references/captured-variables.md @@ -24,6 +24,7 @@ Read this before interpreting unexpected, missing, or truncated captured values, - A captured `Collision2D` is previewed as `{"Collider":{"Name":...,"UnityObjectPath":...},"OtherCollider":{...},"RelativeVelocity":...,"ContactCount":...}` — read `UnityObjectPath` to identify both colliding objects without an extra `execute-dynamic-code` round-trip. Each of `Collider` / `OtherCollider` is either that object form or the string `"(none)"` when the collider is null or destroyed. - A multidimensional array (`int[,]`, `int[,,]`, ...) previews as `{"Shape":"Int32[2,3]","TotalElements":6,"PreviewedElements":6,"ElementOrder":"row-major (last dimension fastest)","Elements":[...]}` instead of a bare JSON array, since `Elements` flattens every rank in row-major order (last dimension fastest) and would otherwise look like an empty or 1D collection; when `--max-preview-elements` cuts the list, the preview also includes `"ElementsTruncated":true` and a smaller `PreviewedElements`. A `T[]` or jagged `T[][]` array is unaffected and still previews as a plain JSON array. - `CapturedVariablesTruncated=true` means at least one value was clipped (value-length cap, collection preview element cap, or preview depth cap) or the variable-count cap stopped enumeration; clipped values are still present up to the cap. +- `TruncatedVariableCount` is the exact number of variables that were dropped whole by the variable-count cap or whose value preview was clipped (`Truncated: true` on the entry). `TruncatedVariableNames` lists that union in capture order, at most 20 names; the count stays exact when more than 20 were affected. The invariant is `CapturedVariablesTruncated == (TruncatedVariableCount > 0)` and `TruncatedVariableCount >= TruncatedVariableNames.Length`. ## Unity Object Values diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCapture.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCapture.cs index e439206ea..93ce1ee7f 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCapture.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCapture.cs @@ -71,7 +71,8 @@ internal static (UloopPausePointCapturedVariableFrame Frame, List variables, bool truncated) = SourcePausePointVariableFormatter.FormatFrame(frame, maxPreviewElements); - return (frame, variables, truncated); + frame = SourcePausePointTruncationAggregate.Merge(frame, variables); + return (frame, variables, truncated || frame.Truncated); } } } diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointTruncationAggregate.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointTruncationAggregate.cs new file mode 100644 index 000000000..06763e0f5 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointTruncationAggregate.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; + +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.Runtime; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Merges count-cap drops with per-entry preview clipping so TruncatedVariableCount + /// and TruncatedVariableNames match CapturedVariablesTruncated. + /// + internal static class SourcePausePointTruncationAggregate + { + public static UloopPausePointCapturedVariableFrame Merge( + UloopPausePointCapturedVariableFrame frame, + IReadOnlyList variables) + { + Debug.Assert(frame != null, "frame must not be null"); + Debug.Assert(variables != null, "variables must not be null"); + + List names = new List(); + HashSet seen = new HashSet(StringComparer.Ordinal); + int previewClippedCount = 0; + + for (int index = 0; index < variables.Count; index++) + { + UloopCapturedVariable variable = variables[index]; + if (!variable.Truncated || string.IsNullOrEmpty(variable.Name) || !seen.Add(variable.Name)) + { + continue; + } + + previewClippedCount++; + TryAddReportedName(names, variable.Name); + } + + IReadOnlyList droppedNames = frame.TruncatedVariableNames; + for (int index = 0; index < droppedNames.Count; index++) + { + string droppedName = droppedNames[index]; + if (string.IsNullOrEmpty(droppedName) || !seen.Add(droppedName)) + { + continue; + } + + TryAddReportedName(names, droppedName); + } + + // Why add the collector count as a whole: TruncatedVariableNames is capped at 20, + // so walking that list cannot recover the exact dropped-whole total. + int count = previewClippedCount + frame.TruncatedVariableCount; + bool truncated = count > 0; + return new UloopPausePointCapturedVariableFrame( + frame.Entries, + truncated, + names, + count); + } + + private static void TryAddReportedName(List names, string name) + { + if (names.Count < SourcePausePointConstants.MaxTruncatedVariableNamesReported) + { + names.Add(name); + } + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointTruncationAggregate.cs.meta b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointTruncationAggregate.cs.meta new file mode 100644 index 000000000..b17936612 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointTruncationAggregate.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2d8cf2f25444d41149902d7e0452302c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointCapturedVariableFrame.cs b/Packages/src/Runtime/PausePoints/UloopPausePointCapturedVariableFrame.cs index a90333b16..882ac5cf5 100644 --- a/Packages/src/Runtime/PausePoints/UloopPausePointCapturedVariableFrame.cs +++ b/Packages/src/Runtime/PausePoints/UloopPausePointCapturedVariableFrame.cs @@ -24,10 +24,12 @@ public UloopPausePointCapturedVariableFrame( public IReadOnlyList Entries { get; } public bool Truncated { get; } - // Names dropped by the variable-count cap (at most MaxTruncatedVariableNamesReported). + // Names dropped by the variable-count cap or whose preview was clipped + // (at most MaxTruncatedVariableNamesReported), in capture order. public IReadOnlyList TruncatedVariableNames { get; } - // Exact number of variables dropped by the count cap (not capped at the names list length). + // Exact number of variables dropped by the count cap or whose preview was + // clipped (not capped at the names list length). public int TruncatedVariableCount { get; } } } 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 dbc0e4b56..d238d6ff4 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 @@ -46,6 +46,7 @@ func filterPausePointCapturedVariablesByName( // 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) + hadListedTruncated := pausePointResponseHasTruncatedCapturedVariable(response) nameSet := make(map[string]struct{}, len(names)) for _, name := range names { @@ -80,7 +81,7 @@ func filterPausePointCapturedVariablesByName( "No captured variable matched the requested names; the hit captured other variables. Check CapturedVariableNamesNotFound for the names that were absent.") } - return applyPausePointCapturedVariablesTruncatedNote(response) + return applyPausePointCapturedVariablesTruncatedNote(response, hadListedTruncated) } // pausePointResponseHasCapturedVariables reports whether the snapshot already holds any @@ -141,16 +142,19 @@ func filterCapturedVariablesByNameSet( // applyPausePointCapturedVariablesTruncatedNote records that the Unity truncation // flag is about a variable the name filter excluded. The flag itself is left // unchanged: clearing it would hide that a captured value was clipped. +// Count is no longer a preview-clip signal: Unity now counts clipped previews +// in TruncatedVariableCount, so a non-zero count must not suppress the note. func applyPausePointCapturedVariablesTruncatedNote( response pausePointStatusResponse, + hadListedTruncatedBeforeFilter bool, ) pausePointStatusResponse { if !response.CapturedVariablesTruncated { return response } - if response.TruncatedVariableCount != 0 { + if pausePointResponseHasTruncatedCapturedVariable(response) { return response } - if pausePointResponseHasTruncatedCapturedVariable(response) { + if !hadListedTruncatedBeforeFilter { return response } 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 e178790ef..336049a41 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 @@ -206,11 +206,12 @@ func TestFilterPausePointCapturedVariablesByNameReportsNotFound(t *testing.T) { } // truncatedNameFilterResponse is a Unity snapshot where preview clipping set -// CapturedVariablesTruncated without a variable-count drop (Count==0). +// CapturedVariablesTruncated and counted the clipped variable. func truncatedNameFilterResponse() pausePointStatusResponse { return pausePointStatusResponse{ CapturedVariablesTruncated: true, - TruncatedVariableCount: 0, + TruncatedVariableCount: 1, + TruncatedVariableNames: []string{"board"}, CapturedVariables: []pausePointCapturedVariable{ {Name: "health", Scope: "Local", TypeName: "Int32", Value: pausePointVariableValue("100")}, {Name: "board", Scope: "Local", TypeName: "Boolean[,]", Value: pausePointVariableValue("[...]"), Truncated: true}, @@ -266,10 +267,15 @@ func TestFilterPausePointCapturedVariablesByNameSetsTruncatedNote(t *testing.T) // TestFilterPausePointCapturedVariablesByNameOmitsTruncatedNote verifies additional // negative cases where the CLI must not attach CapturedVariablesTruncatedNote. func TestFilterPausePointCapturedVariablesByNameOmitsTruncatedNote(t *testing.T) { - t.Run("note is omitted when TruncatedVariableCount is already non-zero", func(t *testing.T) { - response := truncatedNameFilterResponse() - response.TruncatedVariableCount = 1 - response.TruncatedVariableNames = []string{"extraField"} + t.Run("note is omitted when only count-cap drops remain", func(t *testing.T) { + response := pausePointStatusResponse{ + CapturedVariablesTruncated: true, + TruncatedVariableCount: 1, + TruncatedVariableNames: []string{"extraField"}, + CapturedVariables: []pausePointCapturedVariable{ + {Name: "health", Scope: "Local", TypeName: "Int32", Value: pausePointVariableValue("100")}, + }, + } result := filterPausePointCapturedVariablesByName(response, []string{"health"}) if result.CapturedVariablesTruncatedNote != "" { t.Fatalf("count-cap truncation must not be described as a name-filter drop: %q", result.CapturedVariablesTruncatedNote) @@ -288,7 +294,8 @@ func TestFilterPausePointCapturedVariablesByNameOmitsTruncatedNote(t *testing.T) t.Run("note is omitted when only a history variable remains truncated", func(t *testing.T) { response := pausePointStatusResponse{ CapturedVariablesTruncated: true, - TruncatedVariableCount: 0, + TruncatedVariableCount: 1, + TruncatedVariableNames: []string{"board"}, CapturedVariables: []pausePointCapturedVariable{ {Name: "health", Scope: "Local", TypeName: "Int32", Value: pausePointVariableValue("100")}, {Name: "velocity", Scope: "Local", TypeName: "Vector3", Value: pausePointVariableValue("(1,0,0)")}, From f7e137560983ae5b1ed097173b7a8d10a635cbf0 Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 24 Aug 2026 01:56:38 +0900 Subject: [PATCH 2/2] Tighten pause-point truncation note, count, and tests The CLI note must describe only a latest-hit clip excluded by the name filter, and the aggregate count must include every clipped entry even when the name is empty. Tests now lock the full name lists and the constructor asserts the documented invariants. Co-authored-by: Cursor --- .../references/captured-variables.md | 2 +- .../references/captured-variables.md | 2 +- ...ourcePausePointTruncationAggregateTests.cs | 124 ++++++++++++++---- .../Skill/references/captured-variables.md | 2 +- .../SourcePausePointTruncationAggregate.cs | 9 +- .../UloopPausePointCapturedVariableFrame.cs | 10 +- ...se_point_captured_variable_names_filter.go | 30 +++-- ...int_captured_variable_names_filter_test.go | 23 ++++ 8 files changed, 166 insertions(+), 36 deletions(-) diff --git a/.agents/skills/uloop-pause-point/references/captured-variables.md b/.agents/skills/uloop-pause-point/references/captured-variables.md index 9b904b9ff..e6ebb1d81 100644 --- a/.agents/skills/uloop-pause-point/references/captured-variables.md +++ b/.agents/skills/uloop-pause-point/references/captured-variables.md @@ -23,7 +23,7 @@ Read this before interpreting unexpected, missing, or truncated captured values, - A value's `Value` string is not always its plain `ToString()`. A materialized collection (`List`, arrays, dictionaries, ...) previews as a shallow JSON array/object instead of the default type-name text. A custom struct/class whose declared type does not override `ToString()` previews the same way — a shallow JSON object of its fields — so you do not need to add a temporary `ToString()` override just to see its contents. A type that does override `ToString()` keeps using that result unchanged. Either kind of preview is capped by depth, element count, and length like any other captured value; the element-count cap (default 10) and the preview's character budget both scale with `enable-pause-point --max-preview-elements` (1–1000). Raising it scales the character budget proportionally, so each element keeps the same ~100-character share it has at the default — plenty for numeric or boolean cells, but individually long elements can still be clipped by the scaled budget. The enable response echoes the effective `MaxPreviewElements`. - A captured `Collision2D` is previewed as `{"Collider":{"Name":...,"UnityObjectPath":...},"OtherCollider":{...},"RelativeVelocity":...,"ContactCount":...}` — read `UnityObjectPath` to identify both colliding objects without an extra `execute-dynamic-code` round-trip. Each of `Collider` / `OtherCollider` is either that object form or the string `"(none)"` when the collider is null or destroyed. - A multidimensional array (`int[,]`, `int[,,]`, ...) previews as `{"Shape":"Int32[2,3]","TotalElements":6,"PreviewedElements":6,"ElementOrder":"row-major (last dimension fastest)","Elements":[...]}` instead of a bare JSON array, since `Elements` flattens every rank in row-major order (last dimension fastest) and would otherwise look like an empty or 1D collection; when `--max-preview-elements` cuts the list, the preview also includes `"ElementsTruncated":true` and a smaller `PreviewedElements`. A `T[]` or jagged `T[][]` array is unaffected and still previews as a plain JSON array. -- `CapturedVariablesTruncated=true` means at least one value was clipped (value-length cap, collection preview element cap, or preview depth cap) or the variable-count cap stopped enumeration; clipped values are still present up to the cap. +- `CapturedVariablesTruncated=true` means at least one value was clipped (value-length cap or collection preview element cap) or the variable-count cap stopped enumeration; clipped values are still present up to the cap. - `TruncatedVariableCount` is the exact number of variables that were dropped whole by the variable-count cap or whose value preview was clipped (`Truncated: true` on the entry). `TruncatedVariableNames` lists that union in capture order, at most 20 names; the count stays exact when more than 20 were affected. The invariant is `CapturedVariablesTruncated == (TruncatedVariableCount > 0)` and `TruncatedVariableCount >= TruncatedVariableNames.Length`. ## Unity Object Values diff --git a/.claude/skills/uloop-pause-point/references/captured-variables.md b/.claude/skills/uloop-pause-point/references/captured-variables.md index 9b904b9ff..e6ebb1d81 100644 --- a/.claude/skills/uloop-pause-point/references/captured-variables.md +++ b/.claude/skills/uloop-pause-point/references/captured-variables.md @@ -23,7 +23,7 @@ Read this before interpreting unexpected, missing, or truncated captured values, - A value's `Value` string is not always its plain `ToString()`. A materialized collection (`List`, arrays, dictionaries, ...) previews as a shallow JSON array/object instead of the default type-name text. A custom struct/class whose declared type does not override `ToString()` previews the same way — a shallow JSON object of its fields — so you do not need to add a temporary `ToString()` override just to see its contents. A type that does override `ToString()` keeps using that result unchanged. Either kind of preview is capped by depth, element count, and length like any other captured value; the element-count cap (default 10) and the preview's character budget both scale with `enable-pause-point --max-preview-elements` (1–1000). Raising it scales the character budget proportionally, so each element keeps the same ~100-character share it has at the default — plenty for numeric or boolean cells, but individually long elements can still be clipped by the scaled budget. The enable response echoes the effective `MaxPreviewElements`. - A captured `Collision2D` is previewed as `{"Collider":{"Name":...,"UnityObjectPath":...},"OtherCollider":{...},"RelativeVelocity":...,"ContactCount":...}` — read `UnityObjectPath` to identify both colliding objects without an extra `execute-dynamic-code` round-trip. Each of `Collider` / `OtherCollider` is either that object form or the string `"(none)"` when the collider is null or destroyed. - A multidimensional array (`int[,]`, `int[,,]`, ...) previews as `{"Shape":"Int32[2,3]","TotalElements":6,"PreviewedElements":6,"ElementOrder":"row-major (last dimension fastest)","Elements":[...]}` instead of a bare JSON array, since `Elements` flattens every rank in row-major order (last dimension fastest) and would otherwise look like an empty or 1D collection; when `--max-preview-elements` cuts the list, the preview also includes `"ElementsTruncated":true` and a smaller `PreviewedElements`. A `T[]` or jagged `T[][]` array is unaffected and still previews as a plain JSON array. -- `CapturedVariablesTruncated=true` means at least one value was clipped (value-length cap, collection preview element cap, or preview depth cap) or the variable-count cap stopped enumeration; clipped values are still present up to the cap. +- `CapturedVariablesTruncated=true` means at least one value was clipped (value-length cap or collection preview element cap) or the variable-count cap stopped enumeration; clipped values are still present up to the cap. - `TruncatedVariableCount` is the exact number of variables that were dropped whole by the variable-count cap or whose value preview was clipped (`Truncated: true` on the entry). `TruncatedVariableNames` lists that union in capture order, at most 20 names; the count stays exact when more than 20 were affected. The invariant is `CapturedVariablesTruncated == (TruncatedVariableCount > 0)` and `TruncatedVariableCount >= TruncatedVariableNames.Length`. ## Unity Object Values diff --git a/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointTruncationAggregateTests.cs b/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointTruncationAggregateTests.cs index 4e1c83007..1e9d63fa8 100644 --- a/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointTruncationAggregateTests.cs +++ b/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointTruncationAggregateTests.cs @@ -3,6 +3,9 @@ using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; + using io.github.hatayama.UnityCliLoop.FirstPartyTools; using io.github.hatayama.UnityCliLoop.Runtime; @@ -35,15 +38,13 @@ public void CaptureFrame_WhenOnlyPreviewIsClipped_ReportsThatNameAndCountOne() } /// - /// What: count-cap overflow alone keeps 20 reported names and the exact discarded count. + /// What: count-cap overflow alone reports count 25 and local50 through local69 in order. /// [Test] public void CaptureFrame_WhenOnlyCountCapDropsVariables_ReportsTwentyNamesAndExactCount() { - int discarded = SourcePausePointConstants.MaxTruncatedVariableNamesReported + 5; - int localCount = SourcePausePointConstants.MaxCapturedVariableCount + discarded; - object[] locals = new object[localCount * 2]; - for (int index = 0; index < localCount; index++) + object[] locals = new object[150]; + for (int index = 0; index < 75; index++) { locals[index * 2] = $"local{index}"; locals[index * 2 + 1] = index; @@ -53,13 +54,32 @@ public void CaptureFrame_WhenOnlyCountCapDropsVariables_ReportsTwentyNamesAndExa SourcePausePointCapture.CaptureFrame(null, Array.Empty(), locals); Assert.That(truncated, Is.True); - Assert.That(frame.TruncatedVariableCount, Is.EqualTo(discarded)); - Assert.That( - frame.TruncatedVariableNames.Count, - Is.EqualTo(SourcePausePointConstants.MaxTruncatedVariableNamesReported)); + Assert.That(frame.TruncatedVariableCount, Is.EqualTo(25)); Assert.That( - frame.TruncatedVariableNames[0], - Is.EqualTo($"local{SourcePausePointConstants.MaxCapturedVariableCount}")); + frame.TruncatedVariableNames, + Is.EqualTo(new[] + { + "local50", + "local51", + "local52", + "local53", + "local54", + "local55", + "local56", + "local57", + "local58", + "local59", + "local60", + "local61", + "local62", + "local63", + "local64", + "local65", + "local66", + "local67", + "local68", + "local69" + })); } /// @@ -68,12 +88,10 @@ public void CaptureFrame_WhenOnlyCountCapDropsVariables_ReportsTwentyNamesAndExa [Test] public void CaptureFrame_WhenPreviewClipAndCountCapCombine_UnionsNamesInCaptureOrder() { - int discarded = 3; - int localCount = SourcePausePointConstants.MaxCapturedVariableCount + discarded; - object[] locals = new object[localCount * 2]; + object[] locals = new object[106]; locals[0] = "longText"; locals[1] = new string('a', SourcePausePointConstants.MaxCapturedVariableValueLength + 10); - for (int index = 1; index < localCount; index++) + for (int index = 1; index < 53; index++) { locals[index * 2] = $"local{index}"; locals[index * 2 + 1] = index; @@ -83,16 +101,10 @@ public void CaptureFrame_WhenPreviewClipAndCountCapCombine_UnionsNamesInCaptureO SourcePausePointCapture.CaptureFrame(null, Array.Empty(), locals); Assert.That(truncated, Is.True); - Assert.That(frame.TruncatedVariableCount, Is.EqualTo(1 + discarded)); + Assert.That(frame.TruncatedVariableCount, Is.EqualTo(4)); Assert.That( frame.TruncatedVariableNames, - Is.EqualTo(new[] - { - "longText", - $"local{SourcePausePointConstants.MaxCapturedVariableCount}", - $"local{SourcePausePointConstants.MaxCapturedVariableCount + 1}", - $"local{SourcePausePointConstants.MaxCapturedVariableCount + 2}" - })); + Is.EqualTo(new[] { "longText", "local50", "local51", "local52" })); } /// @@ -111,5 +123,71 @@ public void CaptureFrame_WhenNothingIsTruncated_ReportsEmptyAggregate() Assert.That(frame.TruncatedVariableCount, Is.EqualTo(0)); Assert.That(frame.TruncatedVariableNames, Is.Empty); } + + /// + /// What: a clipped value with an empty name still increments the aggregate count. + /// + [Test] + public void CaptureFrame_WhenClippedValueHasEmptyName_CountsItWithoutReportingAName() + { + string longValue = new string('a', SourcePausePointConstants.MaxCapturedVariableValueLength + 10); + object[] locals = { "", longValue }; + + LogAssert.Expect(LogType.Assert, "name must not be null or empty"); + + (UloopPausePointCapturedVariableFrame frame, _, bool truncated) = + SourcePausePointCapture.CaptureFrame(null, Array.Empty(), locals); + + Assert.That(truncated, Is.True); + Assert.That(frame.Truncated, Is.True); + Assert.That(frame.TruncatedVariableCount, Is.EqualTo(1)); + Assert.That(frame.TruncatedVariableNames, Is.Empty); + } + + /// + /// What: more than 20 preview clips keep the first 20 names and the exact total count. + /// + [Test] + public void CaptureFrame_WhenPreviewClipsExceedNameCap_ReportsFirstTwentyNamesAndExactCount() + { + string longValue = new string('a', SourcePausePointConstants.MaxCapturedVariableValueLength + 10); + object[] locals = new object[42]; + for (int index = 0; index < 21; index++) + { + locals[index * 2] = $"clip{index}"; + locals[index * 2 + 1] = longValue; + } + + (UloopPausePointCapturedVariableFrame frame, _, bool truncated) = + SourcePausePointCapture.CaptureFrame(null, Array.Empty(), locals); + + Assert.That(truncated, Is.True); + Assert.That(frame.TruncatedVariableCount, Is.EqualTo(21)); + Assert.That( + frame.TruncatedVariableNames, + Is.EqualTo(new[] + { + "clip0", + "clip1", + "clip2", + "clip3", + "clip4", + "clip5", + "clip6", + "clip7", + "clip8", + "clip9", + "clip10", + "clip11", + "clip12", + "clip13", + "clip14", + "clip15", + "clip16", + "clip17", + "clip18", + "clip19" + })); + } } } diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/references/captured-variables.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/references/captured-variables.md index 9b904b9ff..e6ebb1d81 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/references/captured-variables.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/references/captured-variables.md @@ -23,7 +23,7 @@ Read this before interpreting unexpected, missing, or truncated captured values, - A value's `Value` string is not always its plain `ToString()`. A materialized collection (`List`, arrays, dictionaries, ...) previews as a shallow JSON array/object instead of the default type-name text. A custom struct/class whose declared type does not override `ToString()` previews the same way — a shallow JSON object of its fields — so you do not need to add a temporary `ToString()` override just to see its contents. A type that does override `ToString()` keeps using that result unchanged. Either kind of preview is capped by depth, element count, and length like any other captured value; the element-count cap (default 10) and the preview's character budget both scale with `enable-pause-point --max-preview-elements` (1–1000). Raising it scales the character budget proportionally, so each element keeps the same ~100-character share it has at the default — plenty for numeric or boolean cells, but individually long elements can still be clipped by the scaled budget. The enable response echoes the effective `MaxPreviewElements`. - A captured `Collision2D` is previewed as `{"Collider":{"Name":...,"UnityObjectPath":...},"OtherCollider":{...},"RelativeVelocity":...,"ContactCount":...}` — read `UnityObjectPath` to identify both colliding objects without an extra `execute-dynamic-code` round-trip. Each of `Collider` / `OtherCollider` is either that object form or the string `"(none)"` when the collider is null or destroyed. - A multidimensional array (`int[,]`, `int[,,]`, ...) previews as `{"Shape":"Int32[2,3]","TotalElements":6,"PreviewedElements":6,"ElementOrder":"row-major (last dimension fastest)","Elements":[...]}` instead of a bare JSON array, since `Elements` flattens every rank in row-major order (last dimension fastest) and would otherwise look like an empty or 1D collection; when `--max-preview-elements` cuts the list, the preview also includes `"ElementsTruncated":true` and a smaller `PreviewedElements`. A `T[]` or jagged `T[][]` array is unaffected and still previews as a plain JSON array. -- `CapturedVariablesTruncated=true` means at least one value was clipped (value-length cap, collection preview element cap, or preview depth cap) or the variable-count cap stopped enumeration; clipped values are still present up to the cap. +- `CapturedVariablesTruncated=true` means at least one value was clipped (value-length cap or collection preview element cap) or the variable-count cap stopped enumeration; clipped values are still present up to the cap. - `TruncatedVariableCount` is the exact number of variables that were dropped whole by the variable-count cap or whose value preview was clipped (`Truncated: true` on the entry). `TruncatedVariableNames` lists that union in capture order, at most 20 names; the count stays exact when more than 20 were affected. The invariant is `CapturedVariablesTruncated == (TruncatedVariableCount > 0)` and `TruncatedVariableCount >= TruncatedVariableNames.Length`. ## Unity Object Values diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointTruncationAggregate.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointTruncationAggregate.cs index 06763e0f5..f765dc0b2 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointTruncationAggregate.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointTruncationAggregate.cs @@ -27,12 +27,19 @@ public static UloopPausePointCapturedVariableFrame Merge( for (int index = 0; index < variables.Count; index++) { UloopCapturedVariable variable = variables[index]; - if (!variable.Truncated || string.IsNullOrEmpty(variable.Name) || !seen.Add(variable.Name)) + if (!variable.Truncated) { continue; } + // Why count before name checks: CapturedVariablesTruncated==(Count>0) must + // stay true even when a clipped entry has a null or empty name. previewClippedCount++; + if (string.IsNullOrEmpty(variable.Name) || !seen.Add(variable.Name)) + { + continue; + } + TryAddReportedName(names, variable.Name); } diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointCapturedVariableFrame.cs b/Packages/src/Runtime/PausePoints/UloopPausePointCapturedVariableFrame.cs index 882ac5cf5..93994ca36 100644 --- a/Packages/src/Runtime/PausePoints/UloopPausePointCapturedVariableFrame.cs +++ b/Packages/src/Runtime/PausePoints/UloopPausePointCapturedVariableFrame.cs @@ -2,6 +2,8 @@ using System; using System.Collections.Generic; +using UnityEngine; + namespace io.github.hatayama.UnityCliLoop.Runtime { /// @@ -15,9 +17,15 @@ public UloopPausePointCapturedVariableFrame( IReadOnlyList truncatedVariableNames, int truncatedVariableCount) { + IReadOnlyList names = truncatedVariableNames ?? Array.Empty(); + Debug.Assert(truncated == (truncatedVariableCount > 0), "truncated must match a positive count"); + Debug.Assert( + truncatedVariableCount >= names.Count, + "truncatedVariableCount must be at least the reported name count"); + Entries = entries; Truncated = truncated; - TruncatedVariableNames = truncatedVariableNames ?? Array.Empty(); + TruncatedVariableNames = names; TruncatedVariableCount = truncatedVariableCount; } 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 d238d6ff4..9c1b81c31 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 @@ -46,7 +46,10 @@ func filterPausePointCapturedVariablesByName( // 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) - hadListedTruncated := pausePointResponseHasTruncatedCapturedVariable(response) + // Why current only: top-level CapturedVariablesTruncated/Count/Names come from the + // latest hit. A clipped variable that lives only in history must not make the CLI + // describe a count-cap flag as a name-filter drop. + hadListedTruncated := pausePointCurrentCapturedVariablesHaveTruncated(response) nameSet := make(map[string]struct{}, len(names)) for _, name := range names { @@ -162,19 +165,30 @@ func applyPausePointCapturedVariablesTruncatedNote( return response } +// pausePointCurrentCapturedVariablesHaveTruncated reports whether the latest-hit +// CapturedVariables list (not history) already contains a Truncated entry. +func pausePointCurrentCapturedVariablesHaveTruncated(response pausePointStatusResponse) bool { + return capturedVariablesHaveTruncated(response.CapturedVariables) +} + // pausePointResponseHasTruncatedCapturedVariable reports whether any remaining // captured variable (current or history) still has Truncated set. func pausePointResponseHasTruncatedCapturedVariable(response pausePointStatusResponse) bool { - for _, variable := range response.CapturedVariables { - if variable.Truncated { + if capturedVariablesHaveTruncated(response.CapturedVariables) { + return true + } + for _, frame := range response.CapturedVariableHistory { + if capturedVariablesHaveTruncated(frame.CapturedVariables) { return true } } - for _, frame := range response.CapturedVariableHistory { - for _, variable := range frame.CapturedVariables { - if variable.Truncated { - return true - } + return false +} + +func capturedVariablesHaveTruncated(variables []pausePointCapturedVariable) bool { + for _, variable := range variables { + if variable.Truncated { + return true } } return false 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 336049a41..516302a77 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 @@ -267,6 +267,29 @@ func TestFilterPausePointCapturedVariablesByNameSetsTruncatedNote(t *testing.T) // TestFilterPausePointCapturedVariablesByNameOmitsTruncatedNote verifies additional // negative cases where the CLI must not attach CapturedVariablesTruncatedNote. func TestFilterPausePointCapturedVariablesByNameOmitsTruncatedNote(t *testing.T) { + t.Run("note is omitted when latest hit is count-cap only and history clip is excluded", func(t *testing.T) { + response := pausePointStatusResponse{ + CapturedVariablesTruncated: true, + TruncatedVariableCount: 1, + TruncatedVariableNames: []string{"extraField"}, + CapturedVariables: []pausePointCapturedVariable{ + {Name: "health", Scope: "Local", TypeName: "Int32", Value: pausePointVariableValue("100")}, + }, + CapturedVariableHistory: []pausePointCapturedHistoryFrame{ + { + HitSequence: 1, + CapturedVariables: []pausePointCapturedVariable{ + {Name: "board", Scope: "Local", TypeName: "Boolean[,]", Value: pausePointVariableValue("[...]"), Truncated: true}, + }, + }, + }, + } + result := filterPausePointCapturedVariablesByName(response, []string{"health"}) + if result.CapturedVariablesTruncatedNote != "" { + t.Fatalf("count-cap latest hit must not inherit a history-clip note: %q", result.CapturedVariablesTruncatedNote) + } + }) + t.Run("note is omitted when only count-cap drops remain", func(t *testing.T) { response := pausePointStatusResponse{ CapturedVariablesTruncated: true,