diff --git a/.agents/skills/uloop-pause-point/SKILL.md b/.agents/skills/uloop-pause-point/SKILL.md index c59f9bdd7e..7de52f2e86 100644 --- a/.agents/skills/uloop-pause-point/SKILL.md +++ b/.agents/skills/uloop-pause-point/SKILL.md @@ -123,7 +123,7 @@ Every hit response embeds `CapturedVariables`: the method's in-scope locals, its - The snapshot is taken **before** the resolved line executes, exactly like an IDE breakpoint on that line. To inspect a value after an assignment, place the pause point on the following line. - `Scope` is `Local`, `Parameter`, `InstanceField`, or `This`. The synthetic `this` entry identifies which instance or GameObject was hit via `UnityObjectPath` and `UnityObjectInstanceId`; `UnityEngine.Object` values carry the same handle fields for follow-up digs with `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code`. - `--captured-variables names` on `await-pause-point`/`pause-point-status` drops every `Value` and keeps `Name`/`Scope`/`TypeName` — use it first on field-heavy classes, then fetch full values with a plain `pause-point-status` call. -- When the response would be dominated by variables you do not need, pass `--captured-variable-names velocity,this` (comma-separated, exact match on `Name`) to keep only those entries; it composes with `--captured-variables full|names`. `CapturedVariablesTruncated` in the response reports truncation at Unity-side capture time and is unrelated to this name filter — it can be `true` even when every requested name was found. Requested names that matched nothing are listed in `CapturedVariableNamesNotFound`, so a partial match is visible without comparing the response against the request by hand. +- When the response would be dominated by variables you do not need, pass `--captured-variable-names velocity,this` (comma-separated, exact match on `Name`) to keep only those entries; it composes with `--captured-variables full|names`. `CapturedVariablesTruncated` reports truncation at Unity-side capture time and is independent of this name filter — it can stay `true` even when every listed variable is complete, if a truncated variable was excluded by the filter. In that case the CLI sets `CapturedVariablesTruncatedNote`. Requested names that matched nothing are listed in `CapturedVariableNamesNotFound`, so a partial match is visible without comparing the response against the request by hand. - Pass `--expect 'name=value'` (repeatable; on `await-pause-point`, `enable-pause-point --await`, and `pause-point-status`) to have the CLI compare captured variables against expected values; the response includes an `Expectations` array and `AllExpectationsPassed`, so you do not need to eyeball the JSON. Matching is string equality against the serialized value. On `pause-point-status` a marker that has not been hit yet reports each expectation as not found, and the verdict never changes the exit code — a polling loop reads `AllExpectationsPassed`, not the exit status. Serialized `value` forms that match in practice (string equality against `CapturedVariables[].Value`): - bool: `True` / `False` (C# form, capital first letter) diff --git a/.agents/skills/uloop-pause-point/references/captured-variables.md b/.agents/skills/uloop-pause-point/references/captured-variables.md index 7c6c470d52..bda651376e 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,"Elements":[...]}` instead of a bare JSON array, since `Elements` alone would flatten every rank in row-major order with no way to tell it apart from an empty or 1D collection; 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 to the length 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, collection preview element cap, or preview depth cap) or the variable-count cap stopped enumeration; clipped values are still present up to the cap. ## Unity Object Values diff --git a/.claude/skills/uloop-pause-point/SKILL.md b/.claude/skills/uloop-pause-point/SKILL.md index c59f9bdd7e..7de52f2e86 100644 --- a/.claude/skills/uloop-pause-point/SKILL.md +++ b/.claude/skills/uloop-pause-point/SKILL.md @@ -123,7 +123,7 @@ Every hit response embeds `CapturedVariables`: the method's in-scope locals, its - The snapshot is taken **before** the resolved line executes, exactly like an IDE breakpoint on that line. To inspect a value after an assignment, place the pause point on the following line. - `Scope` is `Local`, `Parameter`, `InstanceField`, or `This`. The synthetic `this` entry identifies which instance or GameObject was hit via `UnityObjectPath` and `UnityObjectInstanceId`; `UnityEngine.Object` values carry the same handle fields for follow-up digs with `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code`. - `--captured-variables names` on `await-pause-point`/`pause-point-status` drops every `Value` and keeps `Name`/`Scope`/`TypeName` — use it first on field-heavy classes, then fetch full values with a plain `pause-point-status` call. -- When the response would be dominated by variables you do not need, pass `--captured-variable-names velocity,this` (comma-separated, exact match on `Name`) to keep only those entries; it composes with `--captured-variables full|names`. `CapturedVariablesTruncated` in the response reports truncation at Unity-side capture time and is unrelated to this name filter — it can be `true` even when every requested name was found. Requested names that matched nothing are listed in `CapturedVariableNamesNotFound`, so a partial match is visible without comparing the response against the request by hand. +- When the response would be dominated by variables you do not need, pass `--captured-variable-names velocity,this` (comma-separated, exact match on `Name`) to keep only those entries; it composes with `--captured-variables full|names`. `CapturedVariablesTruncated` reports truncation at Unity-side capture time and is independent of this name filter — it can stay `true` even when every listed variable is complete, if a truncated variable was excluded by the filter. In that case the CLI sets `CapturedVariablesTruncatedNote`. Requested names that matched nothing are listed in `CapturedVariableNamesNotFound`, so a partial match is visible without comparing the response against the request by hand. - Pass `--expect 'name=value'` (repeatable; on `await-pause-point`, `enable-pause-point --await`, and `pause-point-status`) to have the CLI compare captured variables against expected values; the response includes an `Expectations` array and `AllExpectationsPassed`, so you do not need to eyeball the JSON. Matching is string equality against the serialized value. On `pause-point-status` a marker that has not been hit yet reports each expectation as not found, and the verdict never changes the exit code — a polling loop reads `AllExpectationsPassed`, not the exit status. Serialized `value` forms that match in practice (string equality against `CapturedVariables[].Value`): - bool: `True` / `False` (C# form, capital first letter) diff --git a/.claude/skills/uloop-pause-point/references/captured-variables.md b/.claude/skills/uloop-pause-point/references/captured-variables.md index 7c6c470d52..bda651376e 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,"Elements":[...]}` instead of a bare JSON array, since `Elements` alone would flatten every rank in row-major order with no way to tell it apart from an empty or 1D collection; 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 to the length 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, collection preview element cap, or preview depth cap) or the variable-count cap stopped enumeration; clipped values are still present up to the cap. ## Unity Object Values diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md index c59f9bdd7e..7de52f2e86 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -123,7 +123,7 @@ Every hit response embeds `CapturedVariables`: the method's in-scope locals, its - The snapshot is taken **before** the resolved line executes, exactly like an IDE breakpoint on that line. To inspect a value after an assignment, place the pause point on the following line. - `Scope` is `Local`, `Parameter`, `InstanceField`, or `This`. The synthetic `this` entry identifies which instance or GameObject was hit via `UnityObjectPath` and `UnityObjectInstanceId`; `UnityEngine.Object` values carry the same handle fields for follow-up digs with `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code`. - `--captured-variables names` on `await-pause-point`/`pause-point-status` drops every `Value` and keeps `Name`/`Scope`/`TypeName` — use it first on field-heavy classes, then fetch full values with a plain `pause-point-status` call. -- When the response would be dominated by variables you do not need, pass `--captured-variable-names velocity,this` (comma-separated, exact match on `Name`) to keep only those entries; it composes with `--captured-variables full|names`. `CapturedVariablesTruncated` in the response reports truncation at Unity-side capture time and is unrelated to this name filter — it can be `true` even when every requested name was found. Requested names that matched nothing are listed in `CapturedVariableNamesNotFound`, so a partial match is visible without comparing the response against the request by hand. +- When the response would be dominated by variables you do not need, pass `--captured-variable-names velocity,this` (comma-separated, exact match on `Name`) to keep only those entries; it composes with `--captured-variables full|names`. `CapturedVariablesTruncated` reports truncation at Unity-side capture time and is independent of this name filter — it can stay `true` even when every listed variable is complete, if a truncated variable was excluded by the filter. In that case the CLI sets `CapturedVariablesTruncatedNote`. Requested names that matched nothing are listed in `CapturedVariableNamesNotFound`, so a partial match is visible without comparing the response against the request by hand. - Pass `--expect 'name=value'` (repeatable; on `await-pause-point`, `enable-pause-point --await`, and `pause-point-status`) to have the CLI compare captured variables against expected values; the response includes an `Expectations` array and `AllExpectationsPassed`, so you do not need to eyeball the JSON. Matching is string equality against the serialized value. On `pause-point-status` a marker that has not been hit yet reports each expectation as not found, and the verdict never changes the exit code — a polling loop reads `AllExpectationsPassed`, not the exit status. Serialized `value` forms that match in practice (string equality against `CapturedVariables[].Value`): - bool: `True` / `False` (C# form, capital first letter) 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 7c6c470d52..bda651376e 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,"Elements":[...]}` instead of a bare JSON array, since `Elements` alone would flatten every rank in row-major order with no way to tell it apart from an empty or 1D collection; 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 to the length 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, collection preview element cap, or preview depth cap) or the variable-count cap stopped enumeration; clipped values are still present up to the cap. ## Unity Object Values 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 a6ee22749c..dbc0e4b561 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 @@ -5,6 +5,12 @@ import ( "strings" ) +const ( + // pausePointCapturedVariablesTruncatedNote explains why CapturedVariablesTruncated + // can stay true after --captured-variable-names dropped every clipped variable. + pausePointCapturedVariablesTruncatedNote = "the truncation flag refers to a variable excluded by --captured-variable-names; every variable listed here is complete." +) + // parsePausePointCapturedVariableNames splits the comma-separated --captured-variable-names // value into individual names, trimming surrounding whitespace and dropping empty entries. func parsePausePointCapturedVariableNames(value string) []string { @@ -73,7 +79,8 @@ func filterPausePointCapturedVariablesByName( response.Warning, "No captured variable matched the requested names; the hit captured other variables. Check CapturedVariableNamesNotFound for the names that were absent.") } - return response + + return applyPausePointCapturedVariablesTruncatedNote(response) } // pausePointResponseHasCapturedVariables reports whether the snapshot already holds any @@ -130,3 +137,41 @@ func filterCapturedVariablesByNameSet( } return filtered, len(filtered) } + +// 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. +func applyPausePointCapturedVariablesTruncatedNote( + response pausePointStatusResponse, +) pausePointStatusResponse { + if !response.CapturedVariablesTruncated { + return response + } + if response.TruncatedVariableCount != 0 { + return response + } + if pausePointResponseHasTruncatedCapturedVariable(response) { + return response + } + + response.CapturedVariablesTruncatedNote = pausePointCapturedVariablesTruncatedNote + return response +} + +// 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 { + return true + } + } + for _, frame := range response.CapturedVariableHistory { + for _, variable := range frame.CapturedVariables { + 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 9f6a741a3a..be74db8f77 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 @@ -1,6 +1,10 @@ package projectrunner -import "testing" +import ( + "encoding/json" + "strings" + "testing" +) // capturedVariableNamesFilterResponse is the fixture both --captured-variable-names test functions // filter: three current variables, two of which also appear in one history frame. @@ -200,3 +204,156 @@ func TestFilterPausePointCapturedVariablesByNameReportsNotFound(t *testing.T) { } }) } + +// truncatedNameFilterResponse is a Unity snapshot where preview clipping set +// CapturedVariablesTruncated without a variable-count drop (Count==0). +func truncatedNameFilterResponse() pausePointStatusResponse { + return pausePointStatusResponse{ + CapturedVariablesTruncated: true, + TruncatedVariableCount: 0, + CapturedVariables: []pausePointCapturedVariable{ + {Name: "health", Scope: "Local", TypeName: "Int32", Value: pausePointVariableValue("100")}, + {Name: "board", Scope: "Local", TypeName: "Boolean[,]", Value: pausePointVariableValue("[...]"), Truncated: true}, + }, + CapturedVariableHistory: []pausePointCapturedHistoryFrame{ + { + HitSequence: 1, + CapturedVariables: []pausePointCapturedVariable{ + {Name: "board", Scope: "Local", TypeName: "Boolean[,]", Value: pausePointVariableValue("[...]"), Truncated: true}, + }, + }, + }, + } +} + +// TestFilterPausePointCapturedVariablesByNameSetsTruncatedNote verifies the CLI +// explains CapturedVariablesTruncated when --captured-variable-names dropped every +// truncated variable, and that it does not rewrite the truncation flag. +func TestFilterPausePointCapturedVariablesByNameSetsTruncatedNote(t *testing.T) { + const wantTruncatedNote = "the truncation flag refers to a variable excluded by --captured-variable-names; every variable listed here is complete." + + t.Run("note is set when the truncated variable is excluded", func(t *testing.T) { + result := filterPausePointCapturedVariablesByName(truncatedNameFilterResponse(), []string{"health"}) + if !result.CapturedVariablesTruncated { + t.Fatal("CapturedVariablesTruncated must stay true; the note explains it") + } + if result.CapturedVariablesTruncatedNote != wantTruncatedNote { + t.Fatalf("expected truncated-by-name-filter note: %q", result.CapturedVariablesTruncatedNote) + } + if len(result.CapturedVariables) != 1 || result.CapturedVariables[0].Name != "health" { + t.Fatalf("expected only health to survive: %#v", result.CapturedVariables) + } + }) + + t.Run("note is omitted when a truncated variable remains", func(t *testing.T) { + result := filterPausePointCapturedVariablesByName(truncatedNameFilterResponse(), []string{"board"}) + if result.CapturedVariablesTruncatedNote != "" { + t.Fatalf("listed truncated values must not get the complete-list note: %q", result.CapturedVariablesTruncatedNote) + } + if !result.CapturedVariablesTruncated { + t.Fatal("CapturedVariablesTruncated must stay true") + } + }) + + t.Run("note is omitted when no name filter is applied", func(t *testing.T) { + result := filterPausePointCapturedVariablesByName(truncatedNameFilterResponse(), nil) + if result.CapturedVariablesTruncatedNote != "" { + t.Fatalf("unfiltered responses must not get the CLI note: %q", result.CapturedVariablesTruncatedNote) + } + }) + + t.Run("note is omitted when TruncatedVariableCount is already non-zero", func(t *testing.T) { + response := truncatedNameFilterResponse() + response.TruncatedVariableCount = 1 + response.TruncatedVariableNames = []string{"extraField"} + 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) + } + }) + + t.Run("note is omitted when CapturedVariablesTruncated is false", func(t *testing.T) { + response := truncatedNameFilterResponse() + response.CapturedVariablesTruncated = false + result := filterPausePointCapturedVariablesByName(response, []string{"health"}) + if result.CapturedVariablesTruncatedNote != "" { + t.Fatalf("a non-truncated snapshot must not get the CLI note: %q", result.CapturedVariablesTruncatedNote) + } + }) + + t.Run("note is omitted when only a history variable remains truncated", func(t *testing.T) { + response := pausePointStatusResponse{ + CapturedVariablesTruncated: true, + TruncatedVariableCount: 0, + CapturedVariables: []pausePointCapturedVariable{ + {Name: "health", Scope: "Local", TypeName: "Int32", Value: pausePointVariableValue("100")}, + {Name: "velocity", Scope: "Local", TypeName: "Vector3", Value: pausePointVariableValue("(1,0,0)")}, + }, + CapturedVariableHistory: []pausePointCapturedHistoryFrame{ + { + HitSequence: 1, + CapturedVariables: []pausePointCapturedVariable{ + {Name: "health", Scope: "Local", TypeName: "Int32", Value: pausePointVariableValue("100")}, + {Name: "board", Scope: "Local", TypeName: "Boolean[,]", Value: pausePointVariableValue("[...]"), Truncated: true}, + }, + }, + }, + } + result := filterPausePointCapturedVariablesByName(response, []string{"health", "board"}) + if result.CapturedVariablesTruncatedNote != "" { + t.Fatalf("a truncated history survivor must not get the complete-list note: %q", result.CapturedVariablesTruncatedNote) + } + if len(result.CapturedVariables) != 1 || result.CapturedVariables[0].Truncated { + t.Fatalf("expected only complete current variables: %#v", result.CapturedVariables) + } + if len(result.CapturedVariableHistory) != 1 || + len(result.CapturedVariableHistory[0].CapturedVariables) != 2 || + !result.CapturedVariableHistory[0].CapturedVariables[1].Truncated { + t.Fatalf("expected truncated board to remain in history: %#v", result.CapturedVariableHistory) + } + }) +} + +// TestPausePointStatusResponseIncludesCapturedVariablesTruncatedNote verifies the +// CLI note survives json.Marshal under that exact key. +func TestPausePointStatusResponseIncludesCapturedVariablesTruncatedNote(t *testing.T) { + marshaled, err := json.Marshal(pausePointStatusResponse{ + CapturedVariablesTruncatedNote: pausePointCapturedVariablesTruncatedNote, + }) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded map[string]json.RawMessage + if err := json.Unmarshal(marshaled, &decoded); err != nil { + t.Fatalf("unmarshal envelope failed: %v", err) + } + + rawNote, ok := decoded["CapturedVariablesTruncatedNote"] + if !ok { + t.Fatalf("CapturedVariablesTruncatedNote missing from JSON: %s", marshaled) + } + + var note string + if err := json.Unmarshal(rawNote, ¬e); err != nil { + t.Fatalf("unmarshal note failed: %v", err) + } + if note != pausePointCapturedVariablesTruncatedNote { + t.Fatalf("note mismatch: got %#v, want %#v", note, pausePointCapturedVariablesTruncatedNote) + } +} + +// TestPausePointStatusResponseOmitsEmptyCapturedVariablesTruncatedNote verifies an +// empty note is omitted so unfiltered Unity payloads keep their historical shape. +func TestPausePointStatusResponseOmitsEmptyCapturedVariablesTruncatedNote(t *testing.T) { + marshaled, err := json.Marshal(pausePointStatusResponse{ + CapturedVariablesTruncated: true, + }) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + if strings.Contains(string(marshaled), "CapturedVariablesTruncatedNote") { + t.Fatalf("empty CapturedVariablesTruncatedNote must be omitted from JSON: %s", marshaled) + } +} diff --git a/cli/project-runner/internal/projectrunner/pause_point_status_response_contract_test.go b/cli/project-runner/internal/projectrunner/pause_point_status_response_contract_test.go index 0037857c89..472d8a314c 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_status_response_contract_test.go +++ b/cli/project-runner/internal/projectrunner/pause_point_status_response_contract_test.go @@ -111,6 +111,29 @@ func TestPausePointStatusResponseNormalizesOmittedCallerFrames(t *testing.T) { } } +// TestUnityPausePointStatusContractTruncationInvariant verifies a Unity-originated +// truncated snapshot always has TruncatedVariableCount>0 or a Truncated variable, +// so CapturedVariablesTruncated:true with Count==0 and every listed variable complete +// can only appear after the CLI name filter. +func TestUnityPausePointStatusContractTruncationInvariant(t *testing.T) { + fixture := readPausePointStatusResponseContract(t) + + var response pausePointStatusResponse + if err := json.Unmarshal(fixture, &response); err != nil { + t.Fatalf("failed to unmarshal shared pause point status response contract: %v", err) + } + + if !response.CapturedVariablesTruncated { + t.Fatal("fixture must keep CapturedVariablesTruncated true so the Unity invariant is exercised") + } + if response.TruncatedVariableCount <= 0 && !pausePointResponseHasTruncatedCapturedVariable(response) { + t.Fatal("Unity-originated truncated responses must have TruncatedVariableCount>0 or a Truncated variable") + } + if response.CapturedVariablesTruncatedNote != "" { + t.Fatalf("Unity fixture must not carry the CLI-only note: %q", response.CapturedVariablesTruncatedNote) + } +} + func readPausePointStatusResponseContract(t *testing.T) []byte { t.Helper() diff --git a/cli/project-runner/internal/projectrunner/pause_point_types.go b/cli/project-runner/internal/projectrunner/pause_point_types.go index 10b7c9fc2b..02759b8fe8 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_types.go +++ b/cli/project-runner/internal/projectrunner/pause_point_types.go @@ -32,12 +32,20 @@ type pausePointStatusResponse struct { CapturedVariablesTruncated bool `json:"CapturedVariablesTruncated"` TruncatedVariableNames []string `json:"TruncatedVariableNames"` TruncatedVariableCount int `json:"TruncatedVariableCount"` - ClearedReason string `json:"ClearedReason"` - StatusBeforeClear string `json:"StatusBeforeClear"` - LateHitDiscardedAfterClear bool `json:"LateHitDiscardedAfterClear"` - SuppressedByHotReload bool `json:"SuppressedByHotReload"` - RetargetedToHotReloadPatch bool `json:"RetargetedToHotReloadPatch"` - SuppressedByHotReloadReason string `json:"SuppressedByHotReloadReason,omitempty"` + + // CapturedVariablesTruncatedNote is set by the CLI, not Unity, when + // --captured-variable-names dropped every truncated variable so the remaining + // list is complete even though CapturedVariablesTruncated stays true. + // omitempty keeps the field off unfiltered Unity payloads so the shared + // status contract fixture stays unchanged. + CapturedVariablesTruncatedNote string `json:"CapturedVariablesTruncatedNote,omitempty"` + + ClearedReason string `json:"ClearedReason"` + StatusBeforeClear string `json:"StatusBeforeClear"` + LateHitDiscardedAfterClear bool `json:"LateHitDiscardedAfterClear"` + SuppressedByHotReload bool `json:"SuppressedByHotReload"` + RetargetedToHotReloadPatch bool `json:"RetargetedToHotReloadPatch"` + SuppressedByHotReloadReason string `json:"SuppressedByHotReloadReason,omitempty"` // Warning is set by Unity on enable/clear tool responses when this shared type decodes those // envelopes. The status bridge sets it to SuppressedByHotReloadReason when suppressed.