diff --git a/.agents/skills/uloop-pause-point/SKILL.md b/.agents/skills/uloop-pause-point/SKILL.md index c8cdee79c8..634fff455f 100644 --- a/.agents/skills/uloop-pause-point/SKILL.md +++ b/.agents/skills/uloop-pause-point/SKILL.md @@ -35,7 +35,7 @@ uloop await-pause-point --id "Assets/Scripts/Enemy.cs:42" --timeout-seconds 30 Choose the capture mode when enabling a pause point: - `single-shot` is the default. The first hit pauses Unity and disarms the marker. -- `continuous` pauses Unity on every hit and remains armed. Each hit adds a frame to `CapturedVariableHistory`, while `CapturedVariables` remains the latest-hit compatibility view. +- `continuous` pauses Unity on every hit and remains armed. `CapturedVariables` always holds the latest hit; `CapturedVariableHistory` holds only strictly older frames (the frame matching the latest hit is never repeated there), so with a single hit the history is empty by design. - `trace` remains armed and records each hit without pausing Unity. `--max-history` defaults to 20 and accepts values from 1 through 100. When the limit is exceeded, the oldest frames are dropped and `HistoryDroppedCount` reports how many were removed. `pause-point-status` returns the current `Mode`, `MaxHistory`, history frames, and dropped count. @@ -58,15 +58,36 @@ Every hit response embeds `CapturedVariables`: the method's in-scope locals, its - `execute-dynamic-code` during the pause sees the interrupted method's **post-interrupt** state, not this pre-line snapshot. Use `CapturedVariables` for pre-line evidence; use the raw capture API below when you need live references while paused. If you suspect a captured value is stale or wrong, cross-check it against the live scene object with `execute-dynamic-code` (for example reading `transform.position` off the instance found via `UnityObjectPath`) rather than trusting either source alone. `execute-dynamic-code` responses also carry `EditorPaused` and `ActivePausePointId` — these fields appear only while the Editor is paused, so a call made while a pause point still has Unity paused is unambiguous instead of looking like a stale or buggy result. - `Scope` is `Local`, `Parameter`, `InstanceField`, or `This`. - The snapshot also includes a synthetic `this` entry (Scope `This`) for the paused instance itself, so you can tell which instance or GameObject was hit via its `UnityObjectPath` and `UnityObjectInstanceId`. For an async or coroutine method it resolves to the original outer instance, not the compiler-generated state machine, and static methods emit no `this` entry. While Unity is still paused, `UloopPausePoint.TryGetCapturedValue("this")` returns the live instance reference (for example so a watch expression can read `transform.position`). -- `UnityEngine.Object` values additionally carry `UnityObjectKind` (`SceneObject`, `PrefabAsset`, `Asset`, `RuntimeInstance`, or `Destroyed`), `UnityObjectPath`, and `UnityObjectInstanceId`. Use these as handles for the next dig: a `SceneObject` path feeds `get-hierarchy`/`find-game-objects`, an asset path locates the asset, and the InstanceID works with `execute-dynamic-code`. +- `UnityEngine.Object` values additionally carry `UnityObjectKind` (`SceneObject`, `PrefabAsset`, `Asset`, `RuntimeInstance`, or `Destroyed`), `UnityObjectPath`, and `UnityObjectInstanceId`. These three fields appear only for Unity object values; a non-Unity-object variable (an `int`, a `string`, a plain class) omits all three from the JSON entirely instead of sending them as empty/zero. Check whether `UnityObjectKind` is present to tell the two cases apart. Use the fields as handles for the next dig: a `SceneObject` path feeds `get-hierarchy`/`find-game-objects`, an asset path locates the asset, and the InstanceID works with `execute-dynamic-code`. - `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. - async and coroutine methods work: hoisted locals and the original `this` fields appear under their normal names. - If the patched method ran off the main thread, values degrade to type names with a `(captured off main thread)` note; the hit itself is still recorded. -Read `EvidenceSummary` first when it is present. It groups `EditorState`, pause point hit metadata, matching-log counts, truncation status, and warnings so you can tell whether the evidence is a single clean hit or needs closer inspection. `MatchingLogs` (log entries whose text contains the marker id) is still embedded, but source-derived ids rarely appear in log text, so treat `CapturedVariables` as the primary variable evidence. +`await-pause-point`'s hit response also carries a top-level `Warning` (omitted when empty): it flags multiple hits, multiple matching logs, or truncated matching logs, so you can tell a single clean hit apart from evidence that needs closer inspection. `MatchingLogs` (log entries whose text contains the marker id) is still embedded, but source-derived ids rarely appear in log text, so treat `CapturedVariables` as the primary variable evidence. Use `Generation`, `EnabledAtUtc`, and the hit sequence fields from the hit or status response to tell a fresh marker from stale evidence with the same id. `RemainingMilliseconds` and `Expired` are returned directly so you do not need to infer marker lifetime from elapsed time. +### Pulling More Than the Default Response Carries + +The hit and status responses are push-first and kept lean by default: no field is ever a re-summary of another field, and a variable's `Value` is the only per-entry cost. For a class with dozens of `[SerializeField]` fields, a `continuous` marker's history still multiplies entry count by `MaxHistory` (default 20), which can be a lot of `Value` strings to carry around when you only need to know which names were captured. + +Pull only what you need instead of paying for it all up front: + +- `--captured-variables names` on `await-pause-point`/`pause-point-status` drops `Value` from every captured variable (including every history frame) and keeps `Name`/`Scope`/`TypeName`. Use it first on a field-heavy class, then fetch specific values afterward. +- `uloop pause-point-status --id ` returns the full response again, including every `Value`, whenever you need it — call it plain (no `--captured-variables`) for the complete picture after a lightweight `names` scan. + +### Choosing the Right Evidence Source + +Three different sources answer three different questions about a captured variable; pick by what you actually need: + +| Need | Source | Notes | +|---|---|---| +| A value type's value at capture time | `UloopPausePoint.TryGetCapturedValue("name")` | Faithful: value types are a boxed copy taken at capture time, so this never drifts. | +| A reference type's *live* current state | `UloopPausePoint.TryGetCapturedValue("name")` | The reference itself is live, so the object it points to may have changed since capture (or been destroyed/resumed away). Only available while Unity is still paused. | +| A reference type's state *as it was at capture time* | `uloop pause-point-status --id ` | The only faithful source for this: the response is a formatted string snapshot taken at capture time and stored in the registry, so it never drifts and stays retrievable after resume until the next clear or domain reload. | + +Capturing a deep copy at hit time was deliberately not adopted: it would cost hot-path performance and risk getter side effects, so the formatted-string snapshot (`pause-point-status`) remains the only way to get capture-time-faithful evidence for reference types. + ## Raw Capture While Paused While Unity is paused on a hit, `execute-dynamic-code` can read live captured references through `UloopPausePoint`: diff --git a/.claude/skills/uloop-pause-point/SKILL.md b/.claude/skills/uloop-pause-point/SKILL.md index c8cdee79c8..634fff455f 100644 --- a/.claude/skills/uloop-pause-point/SKILL.md +++ b/.claude/skills/uloop-pause-point/SKILL.md @@ -35,7 +35,7 @@ uloop await-pause-point --id "Assets/Scripts/Enemy.cs:42" --timeout-seconds 30 Choose the capture mode when enabling a pause point: - `single-shot` is the default. The first hit pauses Unity and disarms the marker. -- `continuous` pauses Unity on every hit and remains armed. Each hit adds a frame to `CapturedVariableHistory`, while `CapturedVariables` remains the latest-hit compatibility view. +- `continuous` pauses Unity on every hit and remains armed. `CapturedVariables` always holds the latest hit; `CapturedVariableHistory` holds only strictly older frames (the frame matching the latest hit is never repeated there), so with a single hit the history is empty by design. - `trace` remains armed and records each hit without pausing Unity. `--max-history` defaults to 20 and accepts values from 1 through 100. When the limit is exceeded, the oldest frames are dropped and `HistoryDroppedCount` reports how many were removed. `pause-point-status` returns the current `Mode`, `MaxHistory`, history frames, and dropped count. @@ -58,15 +58,36 @@ Every hit response embeds `CapturedVariables`: the method's in-scope locals, its - `execute-dynamic-code` during the pause sees the interrupted method's **post-interrupt** state, not this pre-line snapshot. Use `CapturedVariables` for pre-line evidence; use the raw capture API below when you need live references while paused. If you suspect a captured value is stale or wrong, cross-check it against the live scene object with `execute-dynamic-code` (for example reading `transform.position` off the instance found via `UnityObjectPath`) rather than trusting either source alone. `execute-dynamic-code` responses also carry `EditorPaused` and `ActivePausePointId` — these fields appear only while the Editor is paused, so a call made while a pause point still has Unity paused is unambiguous instead of looking like a stale or buggy result. - `Scope` is `Local`, `Parameter`, `InstanceField`, or `This`. - The snapshot also includes a synthetic `this` entry (Scope `This`) for the paused instance itself, so you can tell which instance or GameObject was hit via its `UnityObjectPath` and `UnityObjectInstanceId`. For an async or coroutine method it resolves to the original outer instance, not the compiler-generated state machine, and static methods emit no `this` entry. While Unity is still paused, `UloopPausePoint.TryGetCapturedValue("this")` returns the live instance reference (for example so a watch expression can read `transform.position`). -- `UnityEngine.Object` values additionally carry `UnityObjectKind` (`SceneObject`, `PrefabAsset`, `Asset`, `RuntimeInstance`, or `Destroyed`), `UnityObjectPath`, and `UnityObjectInstanceId`. Use these as handles for the next dig: a `SceneObject` path feeds `get-hierarchy`/`find-game-objects`, an asset path locates the asset, and the InstanceID works with `execute-dynamic-code`. +- `UnityEngine.Object` values additionally carry `UnityObjectKind` (`SceneObject`, `PrefabAsset`, `Asset`, `RuntimeInstance`, or `Destroyed`), `UnityObjectPath`, and `UnityObjectInstanceId`. These three fields appear only for Unity object values; a non-Unity-object variable (an `int`, a `string`, a plain class) omits all three from the JSON entirely instead of sending them as empty/zero. Check whether `UnityObjectKind` is present to tell the two cases apart. Use the fields as handles for the next dig: a `SceneObject` path feeds `get-hierarchy`/`find-game-objects`, an asset path locates the asset, and the InstanceID works with `execute-dynamic-code`. - `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. - async and coroutine methods work: hoisted locals and the original `this` fields appear under their normal names. - If the patched method ran off the main thread, values degrade to type names with a `(captured off main thread)` note; the hit itself is still recorded. -Read `EvidenceSummary` first when it is present. It groups `EditorState`, pause point hit metadata, matching-log counts, truncation status, and warnings so you can tell whether the evidence is a single clean hit or needs closer inspection. `MatchingLogs` (log entries whose text contains the marker id) is still embedded, but source-derived ids rarely appear in log text, so treat `CapturedVariables` as the primary variable evidence. +`await-pause-point`'s hit response also carries a top-level `Warning` (omitted when empty): it flags multiple hits, multiple matching logs, or truncated matching logs, so you can tell a single clean hit apart from evidence that needs closer inspection. `MatchingLogs` (log entries whose text contains the marker id) is still embedded, but source-derived ids rarely appear in log text, so treat `CapturedVariables` as the primary variable evidence. Use `Generation`, `EnabledAtUtc`, and the hit sequence fields from the hit or status response to tell a fresh marker from stale evidence with the same id. `RemainingMilliseconds` and `Expired` are returned directly so you do not need to infer marker lifetime from elapsed time. +### Pulling More Than the Default Response Carries + +The hit and status responses are push-first and kept lean by default: no field is ever a re-summary of another field, and a variable's `Value` is the only per-entry cost. For a class with dozens of `[SerializeField]` fields, a `continuous` marker's history still multiplies entry count by `MaxHistory` (default 20), which can be a lot of `Value` strings to carry around when you only need to know which names were captured. + +Pull only what you need instead of paying for it all up front: + +- `--captured-variables names` on `await-pause-point`/`pause-point-status` drops `Value` from every captured variable (including every history frame) and keeps `Name`/`Scope`/`TypeName`. Use it first on a field-heavy class, then fetch specific values afterward. +- `uloop pause-point-status --id ` returns the full response again, including every `Value`, whenever you need it — call it plain (no `--captured-variables`) for the complete picture after a lightweight `names` scan. + +### Choosing the Right Evidence Source + +Three different sources answer three different questions about a captured variable; pick by what you actually need: + +| Need | Source | Notes | +|---|---|---| +| A value type's value at capture time | `UloopPausePoint.TryGetCapturedValue("name")` | Faithful: value types are a boxed copy taken at capture time, so this never drifts. | +| A reference type's *live* current state | `UloopPausePoint.TryGetCapturedValue("name")` | The reference itself is live, so the object it points to may have changed since capture (or been destroyed/resumed away). Only available while Unity is still paused. | +| A reference type's state *as it was at capture time* | `uloop pause-point-status --id ` | The only faithful source for this: the response is a formatted string snapshot taken at capture time and stored in the registry, so it never drifts and stays retrievable after resume until the next clear or domain reload. | + +Capturing a deep copy at hit time was deliberately not adopted: it would cost hot-path performance and risk getter side effects, so the formatted-string snapshot (`pause-point-status`) remains the only way to get capture-time-faithful evidence for reference types. + ## Raw Capture While Paused While Unity is paused on a hit, `execute-dynamic-code` can read live captured references through `UloopPausePoint`: diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md index c8cdee79c8..634fff455f 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -35,7 +35,7 @@ uloop await-pause-point --id "Assets/Scripts/Enemy.cs:42" --timeout-seconds 30 Choose the capture mode when enabling a pause point: - `single-shot` is the default. The first hit pauses Unity and disarms the marker. -- `continuous` pauses Unity on every hit and remains armed. Each hit adds a frame to `CapturedVariableHistory`, while `CapturedVariables` remains the latest-hit compatibility view. +- `continuous` pauses Unity on every hit and remains armed. `CapturedVariables` always holds the latest hit; `CapturedVariableHistory` holds only strictly older frames (the frame matching the latest hit is never repeated there), so with a single hit the history is empty by design. - `trace` remains armed and records each hit without pausing Unity. `--max-history` defaults to 20 and accepts values from 1 through 100. When the limit is exceeded, the oldest frames are dropped and `HistoryDroppedCount` reports how many were removed. `pause-point-status` returns the current `Mode`, `MaxHistory`, history frames, and dropped count. @@ -58,15 +58,36 @@ Every hit response embeds `CapturedVariables`: the method's in-scope locals, its - `execute-dynamic-code` during the pause sees the interrupted method's **post-interrupt** state, not this pre-line snapshot. Use `CapturedVariables` for pre-line evidence; use the raw capture API below when you need live references while paused. If you suspect a captured value is stale or wrong, cross-check it against the live scene object with `execute-dynamic-code` (for example reading `transform.position` off the instance found via `UnityObjectPath`) rather than trusting either source alone. `execute-dynamic-code` responses also carry `EditorPaused` and `ActivePausePointId` — these fields appear only while the Editor is paused, so a call made while a pause point still has Unity paused is unambiguous instead of looking like a stale or buggy result. - `Scope` is `Local`, `Parameter`, `InstanceField`, or `This`. - The snapshot also includes a synthetic `this` entry (Scope `This`) for the paused instance itself, so you can tell which instance or GameObject was hit via its `UnityObjectPath` and `UnityObjectInstanceId`. For an async or coroutine method it resolves to the original outer instance, not the compiler-generated state machine, and static methods emit no `this` entry. While Unity is still paused, `UloopPausePoint.TryGetCapturedValue("this")` returns the live instance reference (for example so a watch expression can read `transform.position`). -- `UnityEngine.Object` values additionally carry `UnityObjectKind` (`SceneObject`, `PrefabAsset`, `Asset`, `RuntimeInstance`, or `Destroyed`), `UnityObjectPath`, and `UnityObjectInstanceId`. Use these as handles for the next dig: a `SceneObject` path feeds `get-hierarchy`/`find-game-objects`, an asset path locates the asset, and the InstanceID works with `execute-dynamic-code`. +- `UnityEngine.Object` values additionally carry `UnityObjectKind` (`SceneObject`, `PrefabAsset`, `Asset`, `RuntimeInstance`, or `Destroyed`), `UnityObjectPath`, and `UnityObjectInstanceId`. These three fields appear only for Unity object values; a non-Unity-object variable (an `int`, a `string`, a plain class) omits all three from the JSON entirely instead of sending them as empty/zero. Check whether `UnityObjectKind` is present to tell the two cases apart. Use the fields as handles for the next dig: a `SceneObject` path feeds `get-hierarchy`/`find-game-objects`, an asset path locates the asset, and the InstanceID works with `execute-dynamic-code`. - `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. - async and coroutine methods work: hoisted locals and the original `this` fields appear under their normal names. - If the patched method ran off the main thread, values degrade to type names with a `(captured off main thread)` note; the hit itself is still recorded. -Read `EvidenceSummary` first when it is present. It groups `EditorState`, pause point hit metadata, matching-log counts, truncation status, and warnings so you can tell whether the evidence is a single clean hit or needs closer inspection. `MatchingLogs` (log entries whose text contains the marker id) is still embedded, but source-derived ids rarely appear in log text, so treat `CapturedVariables` as the primary variable evidence. +`await-pause-point`'s hit response also carries a top-level `Warning` (omitted when empty): it flags multiple hits, multiple matching logs, or truncated matching logs, so you can tell a single clean hit apart from evidence that needs closer inspection. `MatchingLogs` (log entries whose text contains the marker id) is still embedded, but source-derived ids rarely appear in log text, so treat `CapturedVariables` as the primary variable evidence. Use `Generation`, `EnabledAtUtc`, and the hit sequence fields from the hit or status response to tell a fresh marker from stale evidence with the same id. `RemainingMilliseconds` and `Expired` are returned directly so you do not need to infer marker lifetime from elapsed time. +### Pulling More Than the Default Response Carries + +The hit and status responses are push-first and kept lean by default: no field is ever a re-summary of another field, and a variable's `Value` is the only per-entry cost. For a class with dozens of `[SerializeField]` fields, a `continuous` marker's history still multiplies entry count by `MaxHistory` (default 20), which can be a lot of `Value` strings to carry around when you only need to know which names were captured. + +Pull only what you need instead of paying for it all up front: + +- `--captured-variables names` on `await-pause-point`/`pause-point-status` drops `Value` from every captured variable (including every history frame) and keeps `Name`/`Scope`/`TypeName`. Use it first on a field-heavy class, then fetch specific values afterward. +- `uloop pause-point-status --id ` returns the full response again, including every `Value`, whenever you need it — call it plain (no `--captured-variables`) for the complete picture after a lightweight `names` scan. + +### Choosing the Right Evidence Source + +Three different sources answer three different questions about a captured variable; pick by what you actually need: + +| Need | Source | Notes | +|---|---|---| +| A value type's value at capture time | `UloopPausePoint.TryGetCapturedValue("name")` | Faithful: value types are a boxed copy taken at capture time, so this never drifts. | +| A reference type's *live* current state | `UloopPausePoint.TryGetCapturedValue("name")` | The reference itself is live, so the object it points to may have changed since capture (or been destroyed/resumed away). Only available while Unity is still paused. | +| A reference type's state *as it was at capture time* | `uloop pause-point-status --id ` | The only faithful source for this: the response is a formatted string snapshot taken at capture time and stored in the registry, so it never drifts and stays retrievable after resume until the next clear or domain reload. | + +Capturing a deep copy at hit time was deliberately not adopted: it would cost hot-path performance and risk getter side effects, so the formatted-string snapshot (`pause-point-status`) remains the only way to get capture-time-faithful evidence for reference types. + ## Raw Capture While Paused While Unity is paused on a hit, `execute-dynamic-code` can read live captured references through `UloopPausePoint`: diff --git a/cli/common/clicore/command_registry.go b/cli/common/clicore/command_registry.go index 5b3567186e..a1b531c6e9 100644 --- a/cli/common/clicore/command_registry.go +++ b/cli/common/clicore/command_registry.go @@ -1,22 +1,23 @@ package clicore const ( - LaunchCommandName = "launch" - InstallCommandName = "install" - UpdateCommandName = "update" - UninstallCommandName = "uninstall" - SkillsCommandName = "skills" - CompileCommandName = "compile" - ExecuteDynamicCodeCommandName = "execute-dynamic-code" - PausePointAwaitCommandName = "await-pause-point" - PausePointStatusUserCommandName = "pause-point-status" - RunTestsCommandName = "run-tests" - CompletionCommand = "completion" - ListCommandsFlag = "--list-commands" - ListOptionsFlag = "--list-options" - PausePointIDFlagName = "id" - PausePointTimeoutFlagName = "timeout-seconds" - PausePointLogsMaxCountFlagName = "matching-logs-max-count" + LaunchCommandName = "launch" + InstallCommandName = "install" + UpdateCommandName = "update" + UninstallCommandName = "uninstall" + SkillsCommandName = "skills" + CompileCommandName = "compile" + ExecuteDynamicCodeCommandName = "execute-dynamic-code" + PausePointAwaitCommandName = "await-pause-point" + PausePointStatusUserCommandName = "pause-point-status" + RunTestsCommandName = "run-tests" + CompletionCommand = "completion" + ListCommandsFlag = "--list-commands" + ListOptionsFlag = "--list-options" + PausePointIDFlagName = "id" + PausePointTimeoutFlagName = "timeout-seconds" + PausePointLogsMaxCountFlagName = "matching-logs-max-count" + PausePointCapturedVariablesFlagName = "captured-variables" ) type NativeCommandEntry struct { diff --git a/cli/dispatcher/internal/dispatcher/completion_options.go b/cli/dispatcher/internal/dispatcher/completion_options.go index 7fc8943e99..f5f74feb11 100644 --- a/cli/dispatcher/internal/dispatcher/completion_options.go +++ b/cli/dispatcher/internal/dispatcher/completion_options.go @@ -22,10 +22,12 @@ var nativeCommandOptions = map[string][]string{ "--" + clicore.PausePointIDFlagName, "--" + clicore.PausePointTimeoutFlagName, "--" + clicore.PausePointLogsMaxCountFlagName, + "--" + clicore.PausePointCapturedVariablesFlagName, "--" + tooldocs.ProjectPathFlagName, }, clicore.PausePointStatusUserCommandName: { "--" + clicore.PausePointIDFlagName, + "--" + clicore.PausePointCapturedVariablesFlagName, "--" + tooldocs.ProjectPathFlagName, }, } diff --git a/cli/dispatcher/shared-inputs-stamp.json b/cli/dispatcher/shared-inputs-stamp.json index 3623ea3adc..017eab2081 100644 --- a/cli/dispatcher/shared-inputs-stamp.json +++ b/cli/dispatcher/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "ff3f40c09d8fd92060e19a8c7eed079f9293fb23" + "sharedInputsHash": "1177f4566ab96dda739ca71da17255030aa7d7c0" } diff --git a/cli/project-runner/internal/projectrunner/pause_point_captured_variables_mode.go b/cli/project-runner/internal/projectrunner/pause_point_captured_variables_mode.go new file mode 100644 index 0000000000..4ed695862f --- /dev/null +++ b/cli/project-runner/internal/projectrunner/pause_point_captured_variables_mode.go @@ -0,0 +1,61 @@ +package projectrunner + +import ( + clierrors "github.com/hatayama/unity-cli-loop/common/errors" + + "github.com/hatayama/unity-cli-loop/common/clicore" +) + +// pausePointCapturedVariablesMode controls how much of each captured variable's data the CLI +// emits. "full" (default) returns every field; "names" drops Value so agents with wide, +// field-heavy classes can pull a lightweight list first and fetch specific values afterward +// (via TryGetCapturedValue or pause-point-status) instead of paying for every value up front. +type pausePointCapturedVariablesMode string + +const ( + pausePointCapturedVariablesModeFull pausePointCapturedVariablesMode = "full" + pausePointCapturedVariablesModeNames pausePointCapturedVariablesMode = "names" +) + +func parsePausePointCapturedVariablesMode(value string) (pausePointCapturedVariablesMode, error) { + switch value { + case "", string(pausePointCapturedVariablesModeFull): + return pausePointCapturedVariablesModeFull, nil + case string(pausePointCapturedVariablesModeNames): + return pausePointCapturedVariablesModeNames, nil + default: + return "", clierrors.InvalidValueArgumentError( + "--"+clicore.PausePointCapturedVariablesFlagName, value, "full or names") + } +} + +// applyPausePointCapturedVariablesMode strips Value from every captured variable (including +// history frames) when mode is "names", leaving Name/Scope/TypeName as the lightweight result. +func applyPausePointCapturedVariablesMode( + response pausePointStatusResponse, + mode pausePointCapturedVariablesMode, +) pausePointStatusResponse { + if mode != pausePointCapturedVariablesModeNames { + return response + } + + response.CapturedVariables = stripPausePointCapturedVariableValues(response.CapturedVariables) + + history := make([]pausePointCapturedHistoryFrame, len(response.CapturedVariableHistory)) + for index, frame := range response.CapturedVariableHistory { + frame.CapturedVariables = stripPausePointCapturedVariableValues(frame.CapturedVariables) + history[index] = frame + } + response.CapturedVariableHistory = history + + return response +} + +func stripPausePointCapturedVariableValues(variables []pausePointCapturedVariable) []pausePointCapturedVariable { + stripped := make([]pausePointCapturedVariable, len(variables)) + for index, variable := range variables { + variable.Value = nil + stripped[index] = variable + } + return stripped +} diff --git a/cli/project-runner/internal/projectrunner/pause_point_logs.go b/cli/project-runner/internal/projectrunner/pause_point_logs.go index 1b98887bf9..f91b9991c0 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_logs.go +++ b/cli/project-runner/internal/projectrunner/pause_point_logs.go @@ -33,42 +33,15 @@ type pausePointMatchingLogsResult struct { Logs []pausePointMatchingLog `json:"Logs"` } -type pausePointEvidenceSummary struct { - EditorState pausePointEditorState `json:"EditorState"` - PausePoint pausePointEvidencePausePoint `json:"PausePoint"` - MatchingLogs pausePointEvidenceLogs `json:"MatchingLogs"` - Warning string `json:"Warning"` -} - -type pausePointEvidencePausePoint struct { - Id string `json:"Id"` - Status string `json:"Status"` - Generation int `json:"Generation"` - HitCount int `json:"HitCount"` - MultipleHitsObserved bool `json:"MultipleHitsObserved"` - FirstHitAtUtc string `json:"FirstHitAtUtc"` - LastHitAtUtc string `json:"LastHitAtUtc"` - FirstHitSequence int `json:"FirstHitSequence"` - LastHitSequence int `json:"LastHitSequence"` -} - -type pausePointEvidenceLogs struct { - SearchText string `json:"SearchText"` - MatchingLogCount int `json:"MatchingLogCount"` - ReturnedLogCount int `json:"ReturnedLogCount"` - LogType string `json:"LogType"` - MaxCount int `json:"MaxCount"` - IncludeStackTrace bool `json:"IncludeStackTrace"` - MayBeTruncated bool `json:"MayBeTruncated"` - MultipleMatchingLogsObserved bool `json:"MultipleMatchingLogsObserved"` -} - -// pausePointWaitResult extends the hit response with marker-matching logs and -// evidence summary so agents do not need a separate get-logs call while Unity is paused. +// pausePointWaitResult extends the hit response with marker-matching logs so +// agents do not need a separate get-logs call while Unity is paused. Warning +// carries the only actionable signal that used to live inside a now-removed +// EvidenceSummary; everything else in that summary duplicated fields already +// present on pausePointStatusResponse or MatchingLogs. type pausePointWaitResult struct { pausePointStatusResponse - MatchingLogs []pausePointMatchingLog `json:"MatchingLogs"` - EvidenceSummary pausePointEvidenceSummary `json:"EvidenceSummary"` + MatchingLogs []pausePointMatchingLog `json:"MatchingLogs"` + Warning string `json:"Warning,omitempty"` } type pausePointGetLogsResponse struct { @@ -134,55 +107,20 @@ func fetchMatchingLogsFromUnity( }, nil } -func buildPausePointEvidenceSummary( - response pausePointStatusResponse, - logs pausePointMatchingLogsResult, -) pausePointEvidenceSummary { - return pausePointEvidenceSummary{ - EditorState: response.EditorState, - PausePoint: pausePointEvidencePausePoint{ - Id: response.Id, - Status: response.Status, - Generation: response.Generation, - HitCount: response.HitCount, - MultipleHitsObserved: response.HitCount > 1, - FirstHitAtUtc: response.FirstHitAtUtc, - LastHitAtUtc: response.LastHitAtUtc, - FirstHitSequence: response.FirstHitSequence, - LastHitSequence: response.LastHitSequence, - }, - MatchingLogs: buildPausePointEvidenceLogs(logs), - Warning: buildPausePointEvidenceWarning(logs, response.HitCount), - } -} - -func buildPausePointEvidenceLogs(logs pausePointMatchingLogsResult) pausePointEvidenceLogs { +func buildPausePointWarning(logs pausePointMatchingLogsResult, hitCount int) string { matchingLogCount := logs.TotalCount if matchingLogCount < len(logs.Logs) { matchingLogCount = len(logs.Logs) } returnedLogCount := len(logs.Logs) - return pausePointEvidenceLogs{ - SearchText: logs.SearchText, - MatchingLogCount: matchingLogCount, - ReturnedLogCount: returnedLogCount, - LogType: logs.LogType, - MaxCount: logs.MaxCount, - IncludeStackTrace: logs.IncludeStackTrace, - MayBeTruncated: matchingLogCount > returnedLogCount, - MultipleMatchingLogsObserved: matchingLogCount > 1, - } -} -func buildPausePointEvidenceWarning(logs pausePointMatchingLogsResult, hitCount int) string { - evidenceLogs := buildPausePointEvidenceLogs(logs) warnings := []string{} - if evidenceLogs.MayBeTruncated { + if matchingLogCount > returnedLogCount { warnings = append( warnings, "Matching logs may be truncated by --matching-logs-max-count; increase the limit before treating this as complete evidence.") } - if evidenceLogs.MultipleMatchingLogsObserved { + if matchingLogCount > 1 { warnings = append( warnings, "Multiple matching logs were observed for this pause point id; inspect MatchingLogs before treating the scenario as single-fire evidence.") diff --git a/cli/project-runner/internal/projectrunner/pause_point_types.go b/cli/project-runner/internal/projectrunner/pause_point_types.go index b48cce9495..a66fbe8a07 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_types.go +++ b/cli/project-runner/internal/projectrunner/pause_point_types.go @@ -49,12 +49,31 @@ type pausePointCapturedHistoryFrame struct { // pausePointCapturedVariable mirrors the flat Unity-side // PausePointStatusCapturedVariable/UloopCapturedVariable DTO field-for-field: one variable // captured at a source pause point (a local, a parameter, or a `this` instance field). +// +// UnityObjectKind is the discriminator for "is this a Unity object variable", not +// UnityObjectInstanceId: Unity's SourcePausePointVariableFormatter hardcodes all three +// UnityObject* fields to their zero value for non-Unity-object variables, but always sets a +// non-empty Kind for Unity object variables (including destroyed ones). InstanceId can in +// theory land on zero for a real Unity object on 6000.4+ (it is the lower 32 bits of an +// EntityId there), so independent omitempty per field is safe: Kind still surfaces in that +// case, and no consumer needs InstanceId==0 to mean "not a Unity object". type pausePointCapturedVariable struct { - Name string `json:"Name"` - Scope string `json:"Scope"` - TypeName string `json:"TypeName"` - Value string `json:"Value"` - UnityObjectKind string `json:"UnityObjectKind"` - UnityObjectPath string `json:"UnityObjectPath"` - UnityObjectInstanceId int `json:"UnityObjectInstanceId"` + Name string `json:"Name"` + Scope string `json:"Scope"` + TypeName string `json:"TypeName"` + + // Value is a pointer so a genuinely empty string (e.g. a captured `string s = ""`) still + // serializes as "Value":"" in full mode, distinct from names mode setting it to nil to omit + // the field entirely. A plain string with `omitempty` cannot make that distinction, since an + // empty string and an absent value would both be omitted. + Value *string `json:"Value,omitempty"` + UnityObjectKind string `json:"UnityObjectKind,omitempty"` + UnityObjectPath string `json:"UnityObjectPath,omitempty"` + UnityObjectInstanceId int `json:"UnityObjectInstanceId,omitempty"` +} + +// pausePointVariableValue returns a pointer to value for use in pausePointCapturedVariable +// struct literals, where a plain string literal cannot have its address taken inline. +func pausePointVariableValue(value string) *string { + return &value } diff --git a/cli/project-runner/internal/projectrunner/pause_point_wait.go b/cli/project-runner/internal/projectrunner/pause_point_wait.go index f7448f6e9c..619ac1c6e8 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_wait.go +++ b/cli/project-runner/internal/projectrunner/pause_point_wait.go @@ -35,14 +35,16 @@ var ( ) type waitForPausePointOptions struct { - id string - timeoutSeconds int - timeout time.Duration - matchingLogsMaxCount int + id string + timeoutSeconds int + timeout time.Duration + matchingLogsMaxCount int + capturedVariablesMode pausePointCapturedVariablesMode } type pausePointStatusOptions struct { - id string + id string + capturedVariablesMode pausePointCapturedVariablesMode } type pausePointWaitState string @@ -80,6 +82,21 @@ func normalizePausePointStatusResponse(response pausePointStatusResponse) pauseP return response } +// filterPausePointCapturedVariableHistory keeps only frames strictly older than the latest +// hit: CapturedVariables already carries the latest hit's variables, so single-shot mode +// (one hit) always yields an empty history and continuous mode never repeats it. +func filterPausePointCapturedVariableHistory(response pausePointStatusResponse) pausePointStatusResponse { + filtered := make([]pausePointCapturedHistoryFrame, 0, len(response.CapturedVariableHistory)) + for _, frame := range response.CapturedVariableHistory { + if frame.HitSequence == response.LastHitSequence { + continue + } + filtered = append(filtered, frame) + } + response.CapturedVariableHistory = filtered + return response +} + func runWaitForPausePointCommand( ctx context.Context, connection unityipc.Connection, @@ -124,6 +141,8 @@ func runPausePointStatusCommand( return 1 } response = normalizePausePointStatusResponse(response) + response = filterPausePointCapturedVariableHistory(response) + response = applyPausePointCapturedVariablesMode(response, options.capturedVariablesMode) result, err := json.Marshal(response) if err != nil { @@ -158,6 +177,8 @@ func runWaitForPausePoint( } if state == pausePointWaitStateHit { + response = filterPausePointCapturedVariableHistory(response) + response = applyPausePointCapturedVariablesMode(response, options.capturedVariablesMode) // Best-effort: a hit must stay a success even if Unity is busy while paused. // On fetch failure MatchingLogs is omitted entirely, so an empty array always // means "the fetch succeeded and no matching log exists". @@ -167,7 +188,7 @@ func runWaitForPausePoint( payload = pausePointWaitResult{ pausePointStatusResponse: response, MatchingLogs: logs.Logs, - EvidenceSummary: buildPausePointEvidenceSummary(response, logs), + Warning: buildPausePointWarning(logs, response.HitCount), } } result, marshalErr := json.Marshal(payload) @@ -193,7 +214,10 @@ func runWaitForPausePoint( logs, logsErr := fetchMatchingLogs(ctx, connection, options.id, options.matchingLogsMaxCount) if logsErr == nil { waitErr.Details["MatchingLogs"] = logs.Logs - waitErr.Details["EvidenceSummary"] = buildPausePointEvidenceSummary(response, logs) + warning := buildPausePointWarning(logs, response.HitCount) + if warning != "" { + waitErr.Details["Warning"] = warning + } } } clierrors.WriteErrorEnvelope(stderr, waitErr) @@ -202,9 +226,10 @@ func runWaitForPausePoint( func parseWaitForPausePointOptions(args []string) (waitForPausePointOptions, error) { options := waitForPausePointOptions{ - timeoutSeconds: pausePointDefaultTimeoutSeconds, - timeout: time.Duration(pausePointDefaultTimeoutSeconds) * time.Second, - matchingLogsMaxCount: pausePointDefaultLogsMaxCount, + timeoutSeconds: pausePointDefaultTimeoutSeconds, + timeout: time.Duration(pausePointDefaultTimeoutSeconds) * time.Second, + matchingLogsMaxCount: pausePointDefaultLogsMaxCount, + capturedVariablesMode: pausePointCapturedVariablesModeFull, } for index := 0; index < len(args); index++ { @@ -231,6 +256,12 @@ func parseWaitForPausePointOptions(args []string) (waitForPausePointOptions, err "--"+clicore.PausePointLogsMaxCountFlagName, value, "positive integer") } options.matchingLogsMaxCount = maxCount + case clicore.PausePointCapturedVariablesFlagName: + mode, parseErr := parsePausePointCapturedVariablesMode(value) + if parseErr != nil { + return waitForPausePointOptions{}, parseErr + } + options.capturedVariablesMode = mode default: return waitForPausePointOptions{}, &clierrors.ArgumentError{ Message: "Unknown option for await-pause-point: --" + name, @@ -259,7 +290,7 @@ func parseWaitForPausePointOptions(args []string) (waitForPausePointOptions, err } func parsePausePointStatusOptions(args []string) (pausePointStatusOptions, error) { - options := pausePointStatusOptions{} + options := pausePointStatusOptions{capturedVariablesMode: pausePointCapturedVariablesModeFull} for index := 0; index < len(args); index++ { arg := args[index] @@ -271,6 +302,12 @@ func parsePausePointStatusOptions(args []string) (pausePointStatusOptions, error switch name { case clicore.PausePointIDFlagName: options.id = value + case clicore.PausePointCapturedVariablesFlagName: + mode, parseErr := parsePausePointCapturedVariablesMode(value) + if parseErr != nil { + return pausePointStatusOptions{}, parseErr + } + options.capturedVariablesMode = mode default: return pausePointStatusOptions{}, &clierrors.ArgumentError{ Message: "Unknown option for pause-point-status: --" + name, diff --git a/cli/project-runner/internal/projectrunner/pause_point_wait_test.go b/cli/project-runner/internal/projectrunner/pause_point_wait_test.go index 8a9c70a14a..f57929de76 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_wait_test.go +++ b/cli/project-runner/internal/projectrunner/pause_point_wait_test.go @@ -399,6 +399,50 @@ func TestParseWaitForPausePointOptionsParsesMatchingLogFlags(t *testing.T) { } } +// Verifies --captured-variables defaults to full, accepts "names", and rejects other values, +// for both await-pause-point and pause-point-status option parsing. +func TestParsePausePointCapturedVariablesModeFlag(t *testing.T) { + waitDefaults, err := parseWaitForPausePointOptions([]string{"--id", "jump"}) + if err != nil { + t.Fatalf("default parse failed: %v", err) + } + if waitDefaults.capturedVariablesMode != pausePointCapturedVariablesModeFull { + t.Fatalf("default captured-variables mode mismatch: %#v", waitDefaults) + } + + waitNames, err := parseWaitForPausePointOptions([]string{"--id", "jump", "--captured-variables", "names"}) + if err != nil { + t.Fatalf("names parse failed: %v", err) + } + if waitNames.capturedVariablesMode != pausePointCapturedVariablesModeNames { + t.Fatalf("names captured-variables mode mismatch: %#v", waitNames) + } + + if _, err := parseWaitForPausePointOptions([]string{"--id", "jump", "--captured-variables", "bogus"}); err == nil { + t.Fatalf("expected error for invalid captured-variables value") + } + + statusDefaults, err := parsePausePointStatusOptions([]string{"--id", "jump"}) + if err != nil { + t.Fatalf("default status parse failed: %v", err) + } + if statusDefaults.capturedVariablesMode != pausePointCapturedVariablesModeFull { + t.Fatalf("default status captured-variables mode mismatch: %#v", statusDefaults) + } + + statusNames, err := parsePausePointStatusOptions([]string{"--id", "jump", "--captured-variables", "names"}) + if err != nil { + t.Fatalf("names status parse failed: %v", err) + } + if statusNames.capturedVariablesMode != pausePointCapturedVariablesModeNames { + t.Fatalf("names status captured-variables mode mismatch: %#v", statusNames) + } + + if _, err := parsePausePointStatusOptions([]string{"--id", "jump", "--captured-variables", "bogus"}); err == nil { + t.Fatalf("expected error for invalid status captured-variables value") + } +} + // Verifies a hit response always embeds marker-matching logs. func TestRunWaitForPausePointEmbedsMatchingLogsOnHit(t *testing.T) { originalQuery := queryPausePointStatus @@ -477,23 +521,14 @@ func TestRunWaitForPausePointEmbedsMatchingLogsOnHit(t *testing.T) { if result.MatchingLogs[0].StackTrace != "trace one" { t.Fatalf("matching log stack trace mismatch: %#v", result.MatchingLogs) } - if result.EvidenceSummary.EditorState.CapturedAt != "PausePointHit" { - t.Fatalf("editor state summary mismatch: %#v", result.EvidenceSummary) + if result.EditorState.CapturedAt != "PausePointHit" { + t.Fatalf("editor state mismatch: %#v", result.EditorState) } - if result.EvidenceSummary.PausePoint.Generation != 7 || result.EvidenceSummary.PausePoint.FirstHitSequence != 3 { - t.Fatalf("pause point summary mismatch: %#v", result.EvidenceSummary) + if result.Generation != 7 || result.FirstHitSequence != 3 { + t.Fatalf("pause point fields mismatch: %#v", result) } - if !result.EvidenceSummary.MatchingLogs.MultipleMatchingLogsObserved || - result.EvidenceSummary.MatchingLogs.MatchingLogCount != 2 || - result.EvidenceSummary.MatchingLogs.ReturnedLogCount != 2 { - t.Fatalf("matching log summary mismatch: %#v", result.EvidenceSummary) - } - if result.EvidenceSummary.MatchingLogs.LogType != "Error" || - !result.EvidenceSummary.MatchingLogs.IncludeStackTrace { - t.Fatalf("matching log metadata mismatch: %#v", result.EvidenceSummary) - } - if !strings.Contains(result.EvidenceSummary.Warning, "Multiple matching logs") { - t.Fatalf("warning mismatch: %#v", result.EvidenceSummary) + if !strings.Contains(result.Warning, "Multiple matching logs") { + t.Fatalf("warning mismatch: %#v", result.Warning) } } @@ -552,8 +587,11 @@ func TestRunWaitForPausePointEmbedsEmptyMatchingLogsWhenNoneMatch(t *testing.T) if result.MatchingLogs == nil || len(result.MatchingLogs) != 0 { t.Fatalf("MatchingLogs must be an explicit empty array: %#v", result.MatchingLogs) } - if result.EvidenceSummary.Warning != "" { - t.Fatalf("warning should be empty when there are no matching logs: %#v", result.EvidenceSummary) + if result.Warning != "" { + t.Fatalf("warning should be empty when there are no matching logs: %#v", result.Warning) + } + if strings.Contains(stdout.String(), "Warning") { + t.Fatalf("Warning must be omitted from JSON when empty: %s", stdout.String()) } } @@ -603,8 +641,8 @@ func TestRunWaitForPausePointIgnoresLogFetchFailure(t *testing.T) { if strings.Contains(stdout.String(), "MatchingLogs") { t.Fatalf("MatchingLogs must be omitted when the fetch fails: %s", stdout.String()) } - if strings.Contains(stdout.String(), "EvidenceSummary") { - t.Fatalf("EvidenceSummary must be omitted when the fetch fails: %s", stdout.String()) + if strings.Contains(stdout.String(), "Warning") { + t.Fatalf("Warning must be omitted when the fetch fails: %s", stdout.String()) } } @@ -674,13 +712,63 @@ func TestRunWaitForPausePointEmbedsMatchingLogsOnTimeout(t *testing.T) { if !ok || len(matchingLogs) != 1 { t.Fatalf("MatchingLogs detail mismatch: %#v", envelope.Error.Details) } - evidenceSummary, ok := envelope.Error.Details["EvidenceSummary"].(map[string]any) - if !ok { - t.Fatalf("EvidenceSummary detail missing: %#v", envelope.Error.Details) + warning, ok := envelope.Error.Details["Warning"].(string) + if !ok || !strings.Contains(warning, "may be truncated") { + t.Fatalf("Warning detail mismatch: %#v", envelope.Error.Details) + } +} + +// Verifies CapturedVariableHistory never repeats the latest hit: CapturedVariables already +// carries it, so the history must contain only strictly older frames. +func TestFilterPausePointCapturedVariableHistoryExcludesLatestFrame(t *testing.T) { + cases := []struct { + name string + lastHitSequence int + history []pausePointCapturedHistoryFrame + wantSequences []int + }{ + { + name: "single-shot leaves history empty", + lastHitSequence: 1, + history: []pausePointCapturedHistoryFrame{{HitSequence: 1, FrameCount: 10}}, + wantSequences: []int{}, + }, + { + name: "continuous with three hits keeps only older frames", + lastHitSequence: 3, + history: []pausePointCapturedHistoryFrame{ + {HitSequence: 1, FrameCount: 10}, + {HitSequence: 2, FrameCount: 20}, + {HitSequence: 3, FrameCount: 30}, + }, + wantSequences: []int{1, 2}, + }, + { + name: "no history stays empty", + lastHitSequence: 0, + history: nil, + wantSequences: []int{}, + }, } - evidenceLogs, ok := evidenceSummary["MatchingLogs"].(map[string]any) - if !ok || evidenceLogs["MayBeTruncated"] != true || evidenceLogs["MatchingLogCount"] != float64(3) { - t.Fatalf("EvidenceSummary matching logs mismatch: %#v", evidenceSummary) + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + response := filterPausePointCapturedVariableHistory(pausePointStatusResponse{ + LastHitSequence: testCase.lastHitSequence, + CapturedVariableHistory: testCase.history, + }) + + gotSequences := make([]int, len(response.CapturedVariableHistory)) + for index, frame := range response.CapturedVariableHistory { + gotSequences[index] = frame.HitSequence + } + if !reflect.DeepEqual(gotSequences, testCase.wantSequences) { + t.Fatalf("filtered history mismatch: got %#v, want %#v", gotSequences, testCase.wantSequences) + } + if response.CapturedVariableHistory == nil { + t.Fatalf("CapturedVariableHistory must never be nil so the JSON shape stays constant") + } + }) } } @@ -980,13 +1068,13 @@ func TestRunPausePointStatusReturnsCapturedVariables(t *testing.T) { Name: "speed", Scope: "Local", TypeName: "System.Int32", - Value: "5", + Value: pausePointVariableValue("5"), }, { Name: "enemy", Scope: "InstanceField", TypeName: "UnityEngine.GameObject", - Value: "Enemy", + Value: pausePointVariableValue("Enemy"), UnityObjectKind: "SceneObject", UnityObjectPath: "MainScene:/Root/Enemy", UnityObjectInstanceId: -1234, @@ -1018,8 +1106,9 @@ func TestRunPausePointStatusReturnsCapturedVariables(t *testing.T) { if len(response.CapturedVariables) != 2 { t.Fatalf("expected 2 captured variables, got %#v", response.CapturedVariables) } - if response.CapturedVariables[0].Name != "speed" || response.CapturedVariables[0].Value != "5" { - t.Fatalf("first captured variable mismatch: %#v", response.CapturedVariables[0]) + first := response.CapturedVariables[0] + if first.Name != "speed" || first.Value == nil || *first.Value != "5" { + t.Fatalf("first captured variable mismatch: %#v", first) } second := response.CapturedVariables[1] if second.Name != "enemy" || second.UnityObjectKind != "SceneObject" || @@ -1028,6 +1117,70 @@ func TestRunPausePointStatusReturnsCapturedVariables(t *testing.T) { } } +// Verifies --captured-variables names strips Value from every captured variable (including +// history frames) while keeping Name/Scope/TypeName, for pause-point-status output. +func TestRunPausePointStatusCapturedVariablesNamesOmitsValues(t *testing.T) { + originalQuery := queryPausePointStatus + defer func() { + queryPausePointStatus = originalQuery + }() + + queryPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + return pausePointStatusResponse{ + Id: id, + Status: pausePointStatusHit, + IsHit: true, + Mode: "continuous", + LastHitSequence: 2, + CapturedVariables: []pausePointCapturedVariable{ + {Name: "speed", Scope: "Local", TypeName: "System.Int32", Value: pausePointVariableValue("5")}, + }, + CapturedVariableHistory: []pausePointCapturedHistoryFrame{ + { + HitSequence: 1, + CapturedVariables: []pausePointCapturedVariable{ + {Name: "speed", Scope: "Local", TypeName: "System.Int32", Value: pausePointVariableValue("3")}, + }, + }, + }, + }, nil + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runPausePointStatusCommand( + context.Background(), + unityipc.Connection{ProjectRoot: "/tmp/MyProject"}, + []string{"--id", "jump", "--captured-variables", "names"}, + &stdout, + &stderr) + + if code != 0 { + t.Fatalf("expected success, got %d with stderr %s", code, stderr.String()) + } + if strings.Contains(stdout.String(), `"Value"`) { + t.Fatalf("Value must be omitted in names mode: %s", stdout.String()) + } + + var response pausePointStatusResponse + if err := json.Unmarshal(stdout.Bytes(), &response); err != nil { + t.Fatalf("stdout is not valid JSON: %v\n%s", err, stdout.String()) + } + if len(response.CapturedVariables) != 1 || response.CapturedVariables[0].Name != "speed" || + response.CapturedVariables[0].TypeName != "System.Int32" { + t.Fatalf("CapturedVariables mismatch: %#v", response.CapturedVariables) + } + if len(response.CapturedVariableHistory) != 1 || + len(response.CapturedVariableHistory[0].CapturedVariables) != 1 || + response.CapturedVariableHistory[0].CapturedVariables[0].Name != "speed" { + t.Fatalf("CapturedVariableHistory mismatch: %#v", response.CapturedVariableHistory) + } +} + // Verifies pausePointCapturedVariable round-trips through JSON without losing or reordering fields. func TestPausePointStatusResponseCapturedVariablesJSONRoundTrip(t *testing.T) { original := pausePointStatusResponse{ @@ -1038,7 +1191,7 @@ func TestPausePointStatusResponseCapturedVariablesJSONRoundTrip(t *testing.T) { Name: "speed", Scope: "Local", TypeName: "System.Int32", - Value: "5", + Value: pausePointVariableValue("5"), UnityObjectKind: "SceneObject", UnityObjectPath: "MainScene:/Root/Enemy", UnityObjectInstanceId: -1234, @@ -1067,6 +1220,85 @@ func TestPausePointStatusResponseCapturedVariablesJSONRoundTrip(t *testing.T) { } } +// Verifies a non-Unity-object variable omits all three UnityObject* fields from its JSON, +// since Unity always sets them to their zero value ("", "", 0) for such variables. +func TestPausePointCapturedVariableOmitsUnityObjectFieldsWhenNotAUnityObject(t *testing.T) { + variable := pausePointCapturedVariable{ + Name: "speed", + Scope: "Local", + TypeName: "System.Int32", + Value: pausePointVariableValue("5"), + } + + marshaled, err := json.Marshal(variable) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + for _, field := range []string{"UnityObjectKind", "UnityObjectPath", "UnityObjectInstanceId"} { + if strings.Contains(string(marshaled), field) { + t.Fatalf("%s must be omitted for a non-Unity-object variable: %s", field, marshaled) + } + } +} + +// Verifies UnityObjectKind is the discriminator for "is this a Unity object variable": even +// when UnityObjectInstanceId happens to be its zero value, UnityObjectKind (and Path, if set) +// still appear in the JSON because they are non-zero. This locks in the contract that consumers +// must check UnityObjectKind's presence, not UnityObjectInstanceId's value, to detect a Unity +// object variable. +func TestPausePointCapturedVariableKeepsUnityObjectKindWhenInstanceIdIsZero(t *testing.T) { + variable := pausePointCapturedVariable{ + Name: "destroyedEnemy", + Scope: "Local", + TypeName: "UnityEngine.GameObject", + Value: pausePointVariableValue("(destroyed)"), + UnityObjectKind: "Destroyed", + UnityObjectInstanceId: 0, + } + + marshaled, err := json.Marshal(variable) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + if !strings.Contains(string(marshaled), `"UnityObjectKind":"Destroyed"`) { + t.Fatalf("UnityObjectKind must be present when non-empty: %s", marshaled) + } + if strings.Contains(string(marshaled), "UnityObjectInstanceId") { + t.Fatalf("UnityObjectInstanceId must still be omitted when zero, even alongside a non-empty Kind: %s", marshaled) + } +} + +// Verifies a genuinely empty string Value (e.g. a captured `string s = ""`) still serializes as +// "Value":"" in full mode, distinguishable from names mode omitting Value entirely via a nil pointer. +func TestPausePointCapturedVariableKeepsGenuinelyEmptyValueInFullMode(t *testing.T) { + variable := pausePointCapturedVariable{ + Name: "label", + Scope: "Local", + TypeName: "System.String", + Value: pausePointVariableValue(""), + } + + marshaled, err := json.Marshal(variable) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + if !strings.Contains(string(marshaled), `"Value":""`) { + t.Fatalf("a genuinely empty Value must still be serialized in full mode: %s", marshaled) + } + + stripped := stripPausePointCapturedVariableValues([]pausePointCapturedVariable{variable}) + strippedMarshaled, err := json.Marshal(stripped[0]) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + if strings.Contains(string(strippedMarshaled), `"Value"`) { + t.Fatalf("names mode must omit Value entirely, even when the original value was empty: %s", strippedMarshaled) + } +} + // Verifies pause-point-status derives Expired from Status when older Unity packages omit the bool field. func TestRunPausePointStatusDerivesExpiredFromStatus(t *testing.T) { originalQuery := queryPausePointStatus diff --git a/cli/project-runner/shared-inputs-stamp.json b/cli/project-runner/shared-inputs-stamp.json index ba851addc9..b6826b745a 100644 --- a/cli/project-runner/shared-inputs-stamp.json +++ b/cli/project-runner/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "358bda67ddca27ff0f3e01a2a9ecd44630cb37ff" + "sharedInputsHash": "b3533cd7138808911984abbee8c3fbc5c99d3765" }