Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agents/skills/uloop-pause-point/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ Every hit response embeds `CapturedVariables`: the method's in-scope locals, its
- `Scope` is `Local`, `Parameter`, `InstanceField`, or `This`. The synthetic `this` entry identifies which instance or GameObject was hit via `UnityObjectPath` and `UnityObjectInstanceId`; `UnityEngine.Object` values carry the same handle fields for follow-up digs with `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code`.
- `--captured-variables names` on `await-pause-point`/`pause-point-status` drops every `Value` and keeps `Name`/`Scope`/`TypeName` — use it first on field-heavy classes, then fetch full values with a plain `pause-point-status` call.
- When the response would be dominated by variables you do not need, pass `--captured-variable-names velocity,this` (comma-separated, exact match on `Name`) to keep only those entries; it composes with `--captured-variables full|names`.
- Pass `--expect 'name=value'` (repeatable; on `await-pause-point` and `enable-pause-point --await`, not `pause-point-status`) to have the CLI compare captured variables against expected values; the response includes an `Expectations` array and `AllExpectationsPassed`, so you do not need to eyeball the JSON. Matching is string equality against the serialized value.
- While Unity is still paused, `UloopPausePoint.TryGetCapturedValue("name")` (and `"this"`) returns live captured references for `execute-dynamic-code`; the holder clears on resume. (file:line marker hits only — id-only markers store no capture)

Before interpreting unexpected, missing, or truncated values, nested previews that render as type names, Unity-object `Value` strings, capture-time vs live evidence trade-offs, the hit response's `Warning`/`MatchingLogs` fields, marker freshness (`Generation`, `EnabledAtUtc`), or the raw capture API in detail, read [references/captured-variables.md](references/captured-variables.md).
Expand Down
1 change: 1 addition & 0 deletions .claude/skills/uloop-pause-point/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ Every hit response embeds `CapturedVariables`: the method's in-scope locals, its
- `Scope` is `Local`, `Parameter`, `InstanceField`, or `This`. The synthetic `this` entry identifies which instance or GameObject was hit via `UnityObjectPath` and `UnityObjectInstanceId`; `UnityEngine.Object` values carry the same handle fields for follow-up digs with `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code`.
- `--captured-variables names` on `await-pause-point`/`pause-point-status` drops every `Value` and keeps `Name`/`Scope`/`TypeName` — use it first on field-heavy classes, then fetch full values with a plain `pause-point-status` call.
- When the response would be dominated by variables you do not need, pass `--captured-variable-names velocity,this` (comma-separated, exact match on `Name`) to keep only those entries; it composes with `--captured-variables full|names`.
- Pass `--expect 'name=value'` (repeatable; on `await-pause-point` and `enable-pause-point --await`, not `pause-point-status`) to have the CLI compare captured variables against expected values; the response includes an `Expectations` array and `AllExpectationsPassed`, so you do not need to eyeball the JSON. Matching is string equality against the serialized value.
- While Unity is still paused, `UloopPausePoint.TryGetCapturedValue("name")` (and `"this"`) returns live captured references for `execute-dynamic-code`; the holder clears on resume. (file:line marker hits only — id-only markers store no capture)

Before interpreting unexpected, missing, or truncated values, nested previews that render as type names, Unity-object `Value` strings, capture-time vs live evidence trade-offs, the hit response's `Warning`/`MatchingLogs` fields, marker freshness (`Generation`, `EnabledAtUtc`), or the raw capture API in detail, read [references/captured-variables.md](references/captured-variables.md).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ Every hit response embeds `CapturedVariables`: the method's in-scope locals, its
- `Scope` is `Local`, `Parameter`, `InstanceField`, or `This`. The synthetic `this` entry identifies which instance or GameObject was hit via `UnityObjectPath` and `UnityObjectInstanceId`; `UnityEngine.Object` values carry the same handle fields for follow-up digs with `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code`.
- `--captured-variables names` on `await-pause-point`/`pause-point-status` drops every `Value` and keeps `Name`/`Scope`/`TypeName` — use it first on field-heavy classes, then fetch full values with a plain `pause-point-status` call.
- When the response would be dominated by variables you do not need, pass `--captured-variable-names velocity,this` (comma-separated, exact match on `Name`) to keep only those entries; it composes with `--captured-variables full|names`.
- Pass `--expect 'name=value'` (repeatable; on `await-pause-point` and `enable-pause-point --await`, not `pause-point-status`) to have the CLI compare captured variables against expected values; the response includes an `Expectations` array and `AllExpectationsPassed`, so you do not need to eyeball the JSON. Matching is string equality against the serialized value.
- While Unity is still paused, `UloopPausePoint.TryGetCapturedValue("name")` (and `"this"`) returns live captured references for `execute-dynamic-code`; the holder clears on resume. (file:line marker hits only — id-only markers store no capture)

Before interpreting unexpected, missing, or truncated values, nested previews that render as type names, Unity-object `Value` strings, capture-time vs live evidence trade-offs, the hit response's `Warning`/`MatchingLogs` fields, marker freshness (`Generation`, `EnabledAtUtc`), or the raw capture API in detail, read [references/captured-variables.md](references/captured-variables.md).
Expand Down
5 changes: 5 additions & 0 deletions cli/project-runner/internal/projectrunner/list_output.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,11 @@ func appendPausePointEnableAwaitListOptions(tool clicore.ToolDefinition, options
Type: "string",
Description: "Requires --await. Same as await-pause-point's --captured-variable-names",
},
listOption{
Name: "--" + PausePointExpectFlagName,
Type: "string",
Description: "Requires --await. Same as await-pause-point's --expect (repeatable)",
},
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const (
PausePointLogsMaxCountFlagName = "matching-logs-max-count"
PausePointCapturedVariablesFlagName = "captured-variables"
PausePointCapturedVariableNamesFlagName = "captured-variable-names"
PausePointExpectFlagName = "expect"
)

// runnerNativeCommandOptions lists the flags accepted by each runner-owned
Expand All @@ -30,6 +31,7 @@ var runnerNativeCommandOptions = map[string][]string{
"--" + PausePointLogsMaxCountFlagName,
"--" + PausePointCapturedVariablesFlagName,
"--" + PausePointCapturedVariableNamesFlagName,
"--" + PausePointExpectFlagName,
},
clicore.PausePointStatusUserCommandName: {
"--" + PausePointIDFlagName,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ func TestRunProjectLocalAwaitPausePointHelpListsExpectedFlags(t *testing.T) {
if code != 0 {
t.Fatalf("await-pause-point --help failed: code=%d stderr=%s", code, stderr.String())
}
for _, flag := range []string{"--id", "--timeout-seconds", "--matching-logs-max-count", "--captured-variables"} {
for _, flag := range []string{"--id", "--timeout-seconds", "--matching-logs-max-count", "--captured-variables", "--expect"} {
if !strings.Contains(stdout.String(), flag) {
t.Fatalf("await-pause-point --help must list %s: %s", flag, stdout.String())
}
Expand Down
63 changes: 49 additions & 14 deletions cli/project-runner/internal/projectrunner/pause_point_enable.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,18 @@ const pausePointEnableCommandName = "enable-pause-point"
const pausePointEnableAwaitFlagName = "await"

// extractPausePointEnableAwaitFlags pulls the CLI-only --await/--captured-variables/
// --captured-variable-names flags out of enable-pause-point args before generic schema parsing,
// because none of them are part of the Unity-side EnablePausePointSchema.
func extractPausePointEnableAwaitFlags(args []string) ([]string, bool, pausePointCapturedVariablesMode, []string, error) {
// --captured-variable-names/--expect flags out of enable-pause-point args before generic schema
// parsing, because none of them are part of the Unity-side EnablePausePointSchema.
func extractPausePointEnableAwaitFlags(
args []string,
) ([]string, bool, pausePointCapturedVariablesMode, []string, []pausePointExpectation, error) {
remaining := make([]string, 0, len(args))
await := false
mode := pausePointCapturedVariablesModeFull
modeSet := false
var capturedVariableNames []string
namesSet := false
var expectations []pausePointExpectation

for index := 0; index < len(args); index++ {
arg := args[index]
Expand All @@ -46,15 +49,15 @@ func extractPausePointEnableAwaitFlags(args []string) ([]string, bool, pausePoin
if isPausePointFlag(arg, PausePointCapturedVariablesFlagName) {
name, value, consumedNext, err := clicore.ParseFlagValue(arg, args, index)
if err != nil {
return nil, false, mode, nil, err
return nil, false, mode, nil, nil, err
}
if name != PausePointCapturedVariablesFlagName {
remaining = append(remaining, arg)
continue
}
parsedMode, err := parsePausePointCapturedVariablesMode(value)
if err != nil {
return nil, false, mode, nil, err
return nil, false, mode, nil, nil, err
}
mode = parsedMode
modeSet = true
Expand All @@ -67,7 +70,7 @@ func extractPausePointEnableAwaitFlags(args []string) ([]string, bool, pausePoin
if isPausePointFlag(arg, PausePointCapturedVariableNamesFlagName) {
name, value, consumedNext, err := clicore.ParseFlagValue(arg, args, index)
if err != nil {
return nil, false, mode, nil, err
return nil, false, mode, nil, nil, err
}
if name != PausePointCapturedVariableNamesFlagName {
remaining = append(remaining, arg)
Expand All @@ -81,12 +84,32 @@ func extractPausePointEnableAwaitFlags(args []string) ([]string, bool, pausePoin
continue
}

if isPausePointFlag(arg, PausePointExpectFlagName) {
name, value, consumedNext, err := clicore.ParseFlagValue(arg, args, index)
if err != nil {
return nil, false, mode, nil, nil, err
}
if name != PausePointExpectFlagName {
remaining = append(remaining, arg)
continue
}
expectation, parseErr := parsePausePointExpectFlagValue(value)
if parseErr != nil {
return nil, false, mode, nil, nil, parseErr
}
expectations = append(expectations, expectation)
if consumedNext {
index++
}
continue
}

remaining = append(remaining, arg)
}

if !await && (modeSet || namesSet) {
return nil, false, mode, nil, &clierrors.ArgumentError{
Message: "--captured-variables and --captured-variable-names require --await",
if !await && (modeSet || namesSet || len(expectations) > 0) {
return nil, false, mode, nil, nil, &clierrors.ArgumentError{
Message: "--captured-variables, --captured-variable-names, and --expect require --await",
Option: "--" + PausePointCapturedVariablesFlagName,
Command: pausePointEnableCommandName,
NextActions: []string{
Expand All @@ -95,7 +118,7 @@ func extractPausePointEnableAwaitFlags(args []string) ([]string, bool, pausePoin
}
}

return remaining, await, mode, capturedVariableNames, nil
return remaining, await, mode, capturedVariableNames, expectations, nil
}

func isPausePointFlag(arg string, flagName string) bool {
Expand All @@ -110,7 +133,7 @@ func runEnablePausePointCommand(
stdout io.Writer,
stderr io.Writer,
) int {
remainingArgs, await, capturedVariablesMode, capturedVariableNames, err := extractPausePointEnableAwaitFlags(args)
remainingArgs, await, capturedVariablesMode, capturedVariableNames, expectations, err := extractPausePointEnableAwaitFlags(args)
if err != nil {
clierrors.WriteClassifiedError(stderr, err, clierrors.ErrorContext{
ProjectRoot: connection.ProjectRoot,
Expand Down Expand Up @@ -161,7 +184,8 @@ func runEnablePausePointCommand(
return 1
}

return runEnablePausePointAndAwait(ctx, connection, params, capturedVariablesMode, capturedVariableNames, stdout, stderr)
return runEnablePausePointAndAwait(
ctx, connection, params, capturedVariablesMode, capturedVariableNames, expectations, stdout, stderr)
}

// runEnablePausePointAndAwait sends the same single enable-pause-point IPC request the
Expand All @@ -174,6 +198,7 @@ func runEnablePausePointAndAwait(
params map[string]any,
capturedVariablesMode pausePointCapturedVariablesMode,
capturedVariableNames []string,
expectations []pausePointExpectation,
stdout io.Writer,
stderr io.Writer,
) int {
Expand Down Expand Up @@ -221,6 +246,7 @@ func runEnablePausePointAndAwait(
matchingLogsMaxCount: pausePointDefaultLogsMaxCount,
capturedVariablesMode: capturedVariablesMode,
capturedVariableNames: capturedVariableNames,
expectations: expectations,
}

return runPausePointWaitAfterEnable(ctx, connection, waitOptions, enableResponse.Warning, stdout, stderr)
Expand Down Expand Up @@ -250,6 +276,10 @@ func runPausePointWaitAfterEnable(
}

if state == pausePointWaitStateHit {
// Evaluated against the raw, unfiltered CapturedVariables before the filters below can
// narrow or strip values, same as the plain await-pause-point path.
expectations := evaluatePausePointExpectations(response.CapturedVariables, options.expectations)

response = filterPausePointCapturedVariableHistory(response)
response = filterPausePointCapturedVariablesByName(response, options.capturedVariableNames)
response = applyPausePointCapturedVariablesMode(response, options.capturedVariablesMode)
Expand All @@ -262,14 +292,19 @@ func runPausePointWaitAfterEnable(
pausePointStatusResponse: response,
MatchingLogs: logs.Logs,
Warning: joinPausePointWarnings(enableWarning, buildPausePointWarning(logs, response.HitCount)),
Expectations: expectations,
AllExpectationsPassed: pausePointAllExpectationsPassedPointer(expectations),
}
case enableWarning != "":
case enableWarning != "" || len(expectations) > 0:
// Best-effort like the plain await path: a failed log fetch must not also drop the
// enable-time warning, since that is the only warning source left in this branch.
// enable-time warning or --expect results, since those are the only evidence left in
// this branch.
payload = pausePointWaitResult{
pausePointStatusResponse: response,
MatchingLogs: []pausePointMatchingLog{},
Warning: enableWarning,
Expectations: expectations,
AllExpectationsPassed: pausePointAllExpectationsPassedPointer(expectations),
}
}
result, marshalErr := json.Marshal(payload)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import (

// Verifies --await is extracted and the remaining args are left untouched for schema parsing.
func TestExtractPausePointEnableAwaitFlagsExtractsAwait(t *testing.T) {
remaining, await, mode, names, err := extractPausePointEnableAwaitFlags([]string{"--id", "jump", "--await"})
remaining, await, mode, names, expectations, err := extractPausePointEnableAwaitFlags([]string{"--id", "jump", "--await"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand All @@ -29,14 +29,17 @@ func TestExtractPausePointEnableAwaitFlagsExtractsAwait(t *testing.T) {
if names != nil {
t.Fatalf("expected no captured variable names, got %#v", names)
}
if expectations != nil {
t.Fatalf("expected no expectations, got %#v", expectations)
}
if len(remaining) != 2 || remaining[0] != "--id" || remaining[1] != "jump" {
t.Fatalf("remaining args mismatch: %#v", remaining)
}
}

// Verifies --captured-variables/--captured-variable-names are extracted alongside --await.
func TestExtractPausePointEnableAwaitFlagsExtractsCapturedVariableOptions(t *testing.T) {
remaining, await, mode, names, err := extractPausePointEnableAwaitFlags([]string{
remaining, await, mode, names, _, err := extractPausePointEnableAwaitFlags([]string{
"--id", "jump", "--await", "--captured-variables", "names", "--captured-variable-names", "a,b",
})
if err != nil {
Expand All @@ -56,9 +59,45 @@ func TestExtractPausePointEnableAwaitFlagsExtractsCapturedVariableOptions(t *tes
}
}

// Verifies --expect is extracted (repeatably) alongside --await, and unrelated args are untouched.
func TestExtractPausePointEnableAwaitFlagsExtractsExpect(t *testing.T) {
remaining, await, _, _, expectations, err := extractPausePointEnableAwaitFlags([]string{
"--id", "jump", "--await", "--expect", "Health=100", "--expect", "Name=Enemy",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !await {
t.Fatalf("expected await to be true")
}
if len(expectations) != 2 {
t.Fatalf("expectations mismatch: %#v", expectations)
}
if expectations[0] != (pausePointExpectation{Name: "Health", Expected: "100"}) {
t.Fatalf("expectation[0] mismatch: %#v", expectations[0])
}
if expectations[1] != (pausePointExpectation{Name: "Name", Expected: "Enemy"}) {
t.Fatalf("expectation[1] mismatch: %#v", expectations[1])
}
if len(remaining) != 2 || remaining[0] != "--id" || remaining[1] != "jump" {
t.Fatalf("remaining args mismatch: %#v", remaining)
}
}

// Verifies --captured-variables without --await is rejected, since it has no effect otherwise.
func TestExtractPausePointEnableAwaitFlagsRequiresAwaitForCapturedVariables(t *testing.T) {
_, _, _, _, err := extractPausePointEnableAwaitFlags([]string{"--id", "jump", "--captured-variables", "names"})
_, _, _, _, _, err := extractPausePointEnableAwaitFlags([]string{"--id", "jump", "--captured-variables", "names"})
if err == nil {
t.Fatalf("expected an error")
}
if !strings.Contains(err.Error(), "require --await") {
t.Fatalf("error message mismatch: %v", err)
}
}

// Verifies --expect without --await is rejected, since it has no effect otherwise.
func TestExtractPausePointEnableAwaitFlagsRequiresAwaitForExpect(t *testing.T) {
_, _, _, _, _, err := extractPausePointEnableAwaitFlags([]string{"--id", "jump", "--expect", "Health=100"})
if err == nil {
t.Fatalf("expected an error")
}
Expand All @@ -69,7 +108,7 @@ func TestExtractPausePointEnableAwaitFlagsRequiresAwaitForCapturedVariables(t *t

// Verifies enable-pause-point without --await leaves File/Line/Id/Mode args untouched.
func TestExtractPausePointEnableAwaitFlagsWithoutAwaitLeavesArgsUnchanged(t *testing.T) {
remaining, await, _, _, err := extractPausePointEnableAwaitFlags([]string{"--file", "Assets/Foo.cs", "--line", "10"})
remaining, await, _, _, _, err := extractPausePointEnableAwaitFlags([]string{"--file", "Assets/Foo.cs", "--line", "10"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand Down
Loading