From d0eb093f2e0035d9b6fefac05f8871fd64b75488 Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 20 Jul 2026 01:24:55 +0900 Subject: [PATCH 1/7] Remove EvidenceSummary and promote Warning to a top-level field EvidenceSummary in await-pause-point's response duplicated fields already present on pausePointStatusResponse and MatchingLogs (EditorState, PausePoint metadata, log counts). The only field it added that wasn't a duplicate was Warning, so it now lives directly on pausePointWaitResult (and the timeout error Details map) with omitempty, cutting redundant JSON without losing information. --- .../projectrunner/pause_point_logs.go | 82 +++---------------- .../projectrunner/pause_point_wait.go | 7 +- .../projectrunner/pause_point_wait_test.go | 42 ++++------ 3 files changed, 31 insertions(+), 100 deletions(-) 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_wait.go b/cli/project-runner/internal/projectrunner/pause_point_wait.go index f7448f6e9c..0d09add4cd 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_wait.go +++ b/cli/project-runner/internal/projectrunner/pause_point_wait.go @@ -167,7 +167,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 +193,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) 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..67311df347 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_wait_test.go +++ b/cli/project-runner/internal/projectrunner/pause_point_wait_test.go @@ -477,23 +477,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 +543,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 +597,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 +668,9 @@ func TestRunWaitForPausePointEmbedsMatchingLogsOnTimeout(t *testing.T) { if !ok || len(matchingLogs) != 1 { t.Fatalf("MatchingLogs detail mismatch: %#v", envelope.Error.Details) } - evidenceSummary, ok := envelope.Error.Details["EvidenceSummary"].(map[string]any) - if !ok { - t.Fatalf("EvidenceSummary detail missing: %#v", envelope.Error.Details) - } - evidenceLogs, ok := evidenceSummary["MatchingLogs"].(map[string]any) - if !ok || evidenceLogs["MayBeTruncated"] != true || evidenceLogs["MatchingLogCount"] != float64(3) { - t.Fatalf("EvidenceSummary matching logs mismatch: %#v", evidenceSummary) + warning, ok := envelope.Error.Details["Warning"].(string) + if !ok || !strings.Contains(warning, "may be truncated") { + t.Fatalf("Warning detail mismatch: %#v", envelope.Error.Details) } } From 311dcc1b1e1cc8c993a5beacd39b8a32bec0c747 Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 20 Jul 2026 01:27:21 +0900 Subject: [PATCH 2/7] Exclude the latest frame from CapturedVariableHistory CapturedVariables already carries the latest hit's variables, so keeping a matching frame in CapturedVariableHistory duplicated it 100% of the time in single-shot mode (the default) and once per continuous-mode hit. filterPausePointCapturedVariableHistory drops any frame whose HitSequence equals LastHitSequence in both the await-pause-point hit response and pause-point-status output, with a table-driven test covering single-shot, continuous, and no-history cases. --- .../projectrunner/pause_point_wait.go | 17 ++++++ .../projectrunner/pause_point_wait_test.go | 54 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/cli/project-runner/internal/projectrunner/pause_point_wait.go b/cli/project-runner/internal/projectrunner/pause_point_wait.go index 0d09add4cd..34876246b0 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_wait.go +++ b/cli/project-runner/internal/projectrunner/pause_point_wait.go @@ -80,6 +80,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 +139,7 @@ func runPausePointStatusCommand( return 1 } response = normalizePausePointStatusResponse(response) + response = filterPausePointCapturedVariableHistory(response) result, err := json.Marshal(response) if err != nil { @@ -158,6 +174,7 @@ func runWaitForPausePoint( } if state == pausePointWaitStateHit { + response = filterPausePointCapturedVariableHistory(response) // 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". 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 67311df347..87de29a642 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_wait_test.go +++ b/cli/project-runner/internal/projectrunner/pause_point_wait_test.go @@ -674,6 +674,60 @@ func TestRunWaitForPausePointEmbedsMatchingLogsOnTimeout(t *testing.T) { } } +// 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{}, + }, + } + + 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") + } + }) + } +} + // Verifies timeout errors include a deterministic diagnosis hint for common stuck states. func TestPausePointTimeoutErrorIncludesDiagnosisHint(t *testing.T) { cases := []struct { From 87094ee7529c916440f1257b4c9cd06d7fc6bd5c Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 20 Jul 2026 01:31:20 +0900 Subject: [PATCH 3/7] Omit empty UnityObject* fields from captured variable JSON Every non-Unity-object variable always got UnityObjectKind="", UnityObjectPath="", and UnityObjectInstanceId=0 in the response, three fields of pure noise for the common case (int, string, custom class fields). Verified in Unity's SourcePausePointVariableFormatter that non-Unity-object variables always hardcode these three fields to their zero value, and that Unity object variables (including destroyed ones) always set a non-empty UnityObjectKind, making Kind the safe discriminator even in the theoretical case where UnityObjectInstanceId itself lands on zero (possible on 6000.4+, where it is the lower 32 bits of an EntityId). Independent omitempty per field keeps the shape correct in both cases, with tests locking in the discriminator contract. --- .../projectrunner/pause_point_types.go | 14 ++++-- .../projectrunner/pause_point_wait_test.go | 50 +++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/cli/project-runner/internal/projectrunner/pause_point_types.go b/cli/project-runner/internal/projectrunner/pause_point_types.go index b48cce9495..8d12baf971 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_types.go +++ b/cli/project-runner/internal/projectrunner/pause_point_types.go @@ -49,12 +49,20 @@ 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"` + UnityObjectKind string `json:"UnityObjectKind,omitempty"` + UnityObjectPath string `json:"UnityObjectPath,omitempty"` + UnityObjectInstanceId int `json:"UnityObjectInstanceId,omitempty"` } 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 87de29a642..41570597d3 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_wait_test.go +++ b/cli/project-runner/internal/projectrunner/pause_point_wait_test.go @@ -1111,6 +1111,56 @@ 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: "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: "(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 pause-point-status derives Expired from Status when older Unity packages omit the bool field. func TestRunPausePointStatusDerivesExpiredFromStatus(t *testing.T) { originalQuery := queryPausePointStatus From 0ddf7c67a131e81411fbb1918d523a3ab69bdbe1 Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 20 Jul 2026 01:37:03 +0900 Subject: [PATCH 4/7] Add --captured-variables names to await-pause-point/pause-point-status For classes with many SerializeField fields, continuous mode's history already multiplies entry count by MaxHistory, and every entry always carried a Value string. names lets an agent pull a lightweight Name/Scope/TypeName list first and fetch specific values afterward via TryGetCapturedValue or pause-point-status, instead of paying for every value up front. The new flag constant lives in cli/common/clicore (shared with dispatcher shell completion in completion_options.go), so this also updates both project-runner and dispatcher shared-inputs stamps per the release trigger guard. --- cli/common/clicore/command_registry.go | 33 +++--- .../internal/dispatcher/completion_options.go | 2 + cli/dispatcher/shared-inputs-stamp.json | 2 +- .../projectrunner/pause_point_types.go | 2 +- .../projectrunner/pause_point_wait.go | 89 +++++++++++++-- .../projectrunner/pause_point_wait_test.go | 108 ++++++++++++++++++ cli/project-runner/shared-inputs-stamp.json | 2 +- 7 files changed, 210 insertions(+), 28 deletions(-) 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_types.go b/cli/project-runner/internal/projectrunner/pause_point_types.go index 8d12baf971..38e7f9519c 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_types.go +++ b/cli/project-runner/internal/projectrunner/pause_point_types.go @@ -61,7 +61,7 @@ type pausePointCapturedVariable struct { Name string `json:"Name"` Scope string `json:"Scope"` TypeName string `json:"TypeName"` - Value string `json:"Value"` + Value string `json:"Value,omitempty"` UnityObjectKind string `json:"UnityObjectKind,omitempty"` UnityObjectPath string `json:"UnityObjectPath,omitempty"` UnityObjectInstanceId int `json:"UnityObjectInstanceId,omitempty"` diff --git a/cli/project-runner/internal/projectrunner/pause_point_wait.go b/cli/project-runner/internal/projectrunner/pause_point_wait.go index 34876246b0..ae9bbb3e1e 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_wait.go +++ b/cli/project-runner/internal/projectrunner/pause_point_wait.go @@ -35,14 +35,70 @@ 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 +} + +// 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 = "" + stripped[index] = variable + } + return stripped } type pausePointWaitState string @@ -140,6 +196,7 @@ func runPausePointStatusCommand( } response = normalizePausePointStatusResponse(response) response = filterPausePointCapturedVariableHistory(response) + response = applyPausePointCapturedVariablesMode(response, options.capturedVariablesMode) result, err := json.Marshal(response) if err != nil { @@ -175,6 +232,7 @@ 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". @@ -222,9 +280,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++ { @@ -251,6 +310,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, @@ -279,7 +344,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] @@ -291,6 +356,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 41570597d3..b49bfea262 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 @@ -1072,6 +1116,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: "5"}, + }, + CapturedVariableHistory: []pausePointCapturedHistoryFrame{ + { + HitSequence: 1, + CapturedVariables: []pausePointCapturedVariable{ + {Name: "speed", Scope: "Local", TypeName: "System.Int32", Value: "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{ 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" } From 0b7650cae87bc179f6dfac6a40ac8f44f1b651eb Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 20 Jul 2026 01:38:54 +0900 Subject: [PATCH 5/7] Update pause-point SKILL.md for the response diet and sync copies The response shape changed in this PR: EvidenceSummary is gone (Warning moved to a top-level field), CapturedVariableHistory no longer repeats the latest hit, and UnityObject* fields are omitted for non-Unity-object variables. Updated the doc accordingly, added a pull-type usage pattern for --captured-variables names on field-heavy classes, and documented which of TryGetCapturedValue vs. pause-point-status is the faithful evidence source for value types, live reference-type state, and capture-time reference-type state. Synced .claude/skills and .agents/skills byte-identically from the Packages/src source of truth. --- .agents/skills/uloop-pause-point/SKILL.md | 27 ++++++++++++++++--- .claude/skills/uloop-pause-point/SKILL.md | 27 ++++++++++++++++--- .../CliOnlyTools~/PausePoint/Skill/SKILL.md | 27 ++++++++++++++++--- 3 files changed, 72 insertions(+), 9 deletions(-) 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`: From fa2ad2ce8f887d701aaed3b15d281ebe46680868 Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 20 Jul 2026 01:40:28 +0900 Subject: [PATCH 6/7] Split captured-variables mode logic into its own file pause_point_wait.go grew past the 500-line architecture guard (TestProductionGoFilesStayFocused) once the --captured-variables mode type, parser, and stripping logic were added. Moved that functionally-cohesive block (mode type, parse, apply, strip helper) into pause_point_captured_variables_mode.go, no behavior change. --- .../pause_point_captured_variables_mode.go | 61 +++++++++++++++++++ .../projectrunner/pause_point_wait.go | 54 ---------------- 2 files changed, 61 insertions(+), 54 deletions(-) create mode 100644 cli/project-runner/internal/projectrunner/pause_point_captured_variables_mode.go 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..70c2541ef5 --- /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 = "" + stripped[index] = variable + } + return stripped +} diff --git a/cli/project-runner/internal/projectrunner/pause_point_wait.go b/cli/project-runner/internal/projectrunner/pause_point_wait.go index ae9bbb3e1e..619ac1c6e8 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_wait.go +++ b/cli/project-runner/internal/projectrunner/pause_point_wait.go @@ -47,60 +47,6 @@ type pausePointStatusOptions struct { capturedVariablesMode pausePointCapturedVariablesMode } -// 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 = "" - stripped[index] = variable - } - return stripped -} - type pausePointWaitState string const ( From f1e43197a5c9155a2d0aa89af43f0ba90c169016 Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 20 Jul 2026 02:04:46 +0900 Subject: [PATCH 7/7] Preserve genuinely empty captured-variable values in full mode CodeRabbit flagged that Value used a plain string with `omitempty`, which made a legitimately empty value (e.g. a captured `string s = ""`) indistinguishable from names mode stripping the value. Value is now a pointer so full mode keeps "Value":"" for a real empty string while names mode sets it to nil to omit the field entirely. --- .../pause_point_captured_variables_mode.go | 2 +- .../projectrunner/pause_point_types.go | 25 +++++++--- .../projectrunner/pause_point_wait_test.go | 48 +++++++++++++++---- 3 files changed, 58 insertions(+), 17 deletions(-) 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 index 70c2541ef5..4ed695862f 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_captured_variables_mode.go +++ b/cli/project-runner/internal/projectrunner/pause_point_captured_variables_mode.go @@ -54,7 +54,7 @@ func applyPausePointCapturedVariablesMode( func stripPausePointCapturedVariableValues(variables []pausePointCapturedVariable) []pausePointCapturedVariable { stripped := make([]pausePointCapturedVariable, len(variables)) for index, variable := range variables { - variable.Value = "" + variable.Value = nil stripped[index] = variable } return stripped diff --git a/cli/project-runner/internal/projectrunner/pause_point_types.go b/cli/project-runner/internal/projectrunner/pause_point_types.go index 38e7f9519c..a66fbe8a07 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_types.go +++ b/cli/project-runner/internal/projectrunner/pause_point_types.go @@ -58,11 +58,22 @@ type pausePointCapturedHistoryFrame struct { // 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,omitempty"` - UnityObjectKind string `json:"UnityObjectKind,omitempty"` - UnityObjectPath string `json:"UnityObjectPath,omitempty"` - UnityObjectInstanceId int `json:"UnityObjectInstanceId,omitempty"` + 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_test.go b/cli/project-runner/internal/projectrunner/pause_point_wait_test.go index b49bfea262..f57929de76 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_wait_test.go +++ b/cli/project-runner/internal/projectrunner/pause_point_wait_test.go @@ -1068,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, @@ -1106,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" || @@ -1136,13 +1137,13 @@ func TestRunPausePointStatusCapturedVariablesNamesOmitsValues(t *testing.T) { Mode: "continuous", LastHitSequence: 2, CapturedVariables: []pausePointCapturedVariable{ - {Name: "speed", Scope: "Local", TypeName: "System.Int32", Value: "5"}, + {Name: "speed", Scope: "Local", TypeName: "System.Int32", Value: pausePointVariableValue("5")}, }, CapturedVariableHistory: []pausePointCapturedHistoryFrame{ { HitSequence: 1, CapturedVariables: []pausePointCapturedVariable{ - {Name: "speed", Scope: "Local", TypeName: "System.Int32", Value: "3"}, + {Name: "speed", Scope: "Local", TypeName: "System.Int32", Value: pausePointVariableValue("3")}, }, }, }, @@ -1190,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, @@ -1226,7 +1227,7 @@ func TestPausePointCapturedVariableOmitsUnityObjectFieldsWhenNotAUnityObject(t * Name: "speed", Scope: "Local", TypeName: "System.Int32", - Value: "5", + Value: pausePointVariableValue("5"), } marshaled, err := json.Marshal(variable) @@ -1251,7 +1252,7 @@ func TestPausePointCapturedVariableKeepsUnityObjectKindWhenInstanceIdIsZero(t *t Name: "destroyedEnemy", Scope: "Local", TypeName: "UnityEngine.GameObject", - Value: "(destroyed)", + Value: pausePointVariableValue("(destroyed)"), UnityObjectKind: "Destroyed", UnityObjectInstanceId: 0, } @@ -1269,6 +1270,35 @@ func TestPausePointCapturedVariableKeepsUnityObjectKindWhenInstanceIdIsZero(t *t } } +// 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