From 3799d953eb33d1f571ad3412aa7f0a6371f4f3d9 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 11 Jun 2026 01:10:59 +0900 Subject: [PATCH 01/31] Rename debug-break naming to pause-point across CLI, package, and skills AI-agent usability feedback from three independent E2E sessions showed the mixed naming between the "pause point" concept and the "debug-break" command family caused real failures (agents guessed clear-pause-point and got Unknown tool). Unify every user-facing name on pause-point with no backward compatibility: - CLI commands: enable/clear/wait-for-pause-point, pause-point-status, and the internal bridge commands get/clear-pause-point-status - Error codes: PAUSE_POINT_NOT_ENABLED / WAIT_TIMEOUT / EXPIRED / CLEARED - Response fields: InterruptedByPausePoint, PausePointId, PausePointHitCount - Runtime marker API: UnityCliLoopDebug.Break(id) -> UloopPausePoint.Pause(id) - Skill renamed to uloop-wait-for-pause-point; regenerated copies updated - Bump cli contract and MINIMUM_REQUIRED_CLI_VERSION to 3.0.0-beta.29 because the Unity package now depends on the renamed bridge commands --- .../uloop-execute-dynamic-code/SKILL.md | 4 +- .agents/skills/uloop-get-logs/SKILL.md | 4 +- .../skills/uloop-simulate-keyboard/SKILL.md | 38 +- .../uloop-simulate-mouse-input/SKILL.md | 58 +- .../skills/uloop-simulate-mouse-ui/SKILL.md | 20 +- .../uloop-wait-for-debug-break/SKILL.md | 58 -- .../uloop-wait-for-pause-point/SKILL.md | 67 +++ .../uloop-execute-dynamic-code/SKILL.md | 4 +- .claude/skills/uloop-get-logs/SKILL.md | 4 +- .../skills/uloop-simulate-keyboard/SKILL.md | 38 +- .../uloop-simulate-mouse-input/SKILL.md | 58 +- .../skills/uloop-simulate-mouse-ui/SKILL.md | 20 +- .../uloop-wait-for-debug-break/SKILL.md | 58 -- .../uloop-wait-for-pause-point/SKILL.md | 67 +++ Assets/Tests/Editor/PausePointTests.cs | 32 +- .../Tests/Editor/ToolSettingsUseCaseTests.cs | 8 +- .../Editor/UnityCliLoopToolRegistryTests.cs | 8 +- .../Tests/PlayMode/SimulateKeyboardTests.cs | 24 +- .../Tests/PlayMode/SimulateMouseInputTests.cs | 42 +- .../UseCases/ToolSettingsUseCase.cs | 4 +- .../CliOnlyTools~/PausePoint/Skill/SKILL.md | 20 +- Packages/src/Editor/Domain/CliConstants.cs | 2 +- .../UnityCliLoopInputSimulationTypes.cs | 12 +- .../ExecuteDynamicCode/Skill/SKILL.md | 2 +- .../FirstPartyTools/GetLogs/Skill/SKILL.md | 4 +- .../PausePoint/PausePointTools.cs | 20 +- .../SimulateKeyboardResponse.cs | 6 +- .../SimulateKeyboard/SimulateKeyboardTool.cs | 6 +- .../SimulateKeyboardUseCase.cs | 12 +- .../SimulateKeyboard/Skill/SKILL.md | 16 +- .../SimulateMouseInputResponse.cs | 6 +- .../SimulateMouseInputTool.cs | 6 +- .../SimulateMouseInputUseCase.cs | 12 +- .../SimulateMouseInput/Skill/SKILL.md | 22 +- .../SimulateMouseUi/Skill/SKILL.md | 14 +- .../Api/InternalBridgeCommandRouter.cs | 8 +- .../Api/PausePointStatusBridgeCommand.cs | 4 +- .../ToolContracts/UnityCliLoopConstants.cs | 12 +- ...nityCliLoopDebug.cs => UloopPausePoint.cs} | 8 +- ...pDebug.cs.meta => UloopPausePoint.cs.meta} | 0 .../PausePoints/UloopPausePointRegistry.cs | 10 +- cli/contract.json | 2 +- cli/internal/cli/command_help.go | 2 +- cli/internal/cli/command_registry.go | 4 +- cli/internal/cli/completion_options.go | 10 +- cli/internal/cli/debug_break_wait.go | 494 ------------------ cli/internal/cli/error_envelope.go | 8 +- cli/internal/cli/error_envelope_test.go | 2 +- cli/internal/cli/native_tool_settings.go | 2 +- cli/internal/cli/pause_point_wait.go | 494 ++++++++++++++++++ ..._wait_test.go => pause_point_wait_test.go} | 216 ++++---- cli/internal/cli/run.go | 8 +- cli/internal/tools/default-tools.json | 14 +- 53 files changed, 1064 insertions(+), 1010 deletions(-) delete mode 100644 .agents/skills/uloop-wait-for-debug-break/SKILL.md create mode 100644 .agents/skills/uloop-wait-for-pause-point/SKILL.md delete mode 100644 .claude/skills/uloop-wait-for-debug-break/SKILL.md create mode 100644 .claude/skills/uloop-wait-for-pause-point/SKILL.md rename Packages/src/Runtime/PausePoints/{UnityCliLoopDebug.cs => UloopPausePoint.cs} (57%) rename Packages/src/Runtime/PausePoints/{UnityCliLoopDebug.cs.meta => UloopPausePoint.cs.meta} (100%) delete mode 100644 cli/internal/cli/debug_break_wait.go create mode 100644 cli/internal/cli/pause_point_wait.go rename cli/internal/cli/{debug_break_wait_test.go => pause_point_wait_test.go} (55%) diff --git a/.agents/skills/uloop-execute-dynamic-code/SKILL.md b/.agents/skills/uloop-execute-dynamic-code/SKILL.md index d29fcdc9c..225443be9 100644 --- a/.agents/skills/uloop-execute-dynamic-code/SKILL.md +++ b/.agents/skills/uloop-execute-dynamic-code/SKILL.md @@ -1,7 +1,7 @@ --- name: uloop-execute-dynamic-code toolName: execute-dynamic-code -description: "Execute C# with Unity APIs when existing uloop tools cannot inspect or edit enough. Use for scene, prefab, SerializedObject, AssetDatabase refresh/.meta generation, menu, or PlayMode automation." +description: "Execute C# with Unity APIs when existing uloop tools cannot inspect or edit enough. Use for reachable scene/component state, scene/prefab/menu automation, and PlayMode checks" context: fork --- @@ -11,6 +11,8 @@ Execute the following request using `uloop execute-dynamic-code`: $ARGUMENTS For basic selected GameObject discovery or property inspection, use `find-game-objects --search-mode Selected` before this tool. Use this tool after the built-in inspection tools are not enough or when you need to modify Unity state. +This tool can inspect reachable Unity state, such as GameObjects, components, public properties, static values, and method results. It cannot directly read local variables or intermediate calculations inside an already-running method. When those values matter, add a focused `Debug.Log` immediately before `UloopPausePoint.Pause("")`, then run `get-logs --search-text ` while Unity is paused. Do not replace that log read with execute-dynamic-code. + ## Workflow 1. Read the relevant reference file(s) from the Code Examples section below diff --git a/.agents/skills/uloop-get-logs/SKILL.md b/.agents/skills/uloop-get-logs/SKILL.md index a74e55532..dd10950d4 100644 --- a/.agents/skills/uloop-get-logs/SKILL.md +++ b/.agents/skills/uloop-get-logs/SKILL.md @@ -1,13 +1,15 @@ --- name: uloop-get-logs toolName: get-logs -description: "Read current Unity Console entries from a running Editor. Use during bug investigation after compile, tests, PlayMode, or dynamic code to inspect logs, warnings, errors, and stack traces." +description: "Read current Unity Console entries from a running Editor. Use during bug investigation after compile, tests, PlayMode, dynamic code, or immediately after `uloop-wait-for-pause-point`." --- # uloop get-logs Retrieve logs from Unity Console. +When a pause-point marker pauses Unity and the value you need is a method local, intermediate calculation, or branch reason, read the focused `Debug.Log` entry added immediately before the marker before resuming PlayMode. Use `--search-text ` so the marker and its log are checked as one breakpoint-style proof. + ## Usage ```bash diff --git a/.agents/skills/uloop-simulate-keyboard/SKILL.md b/.agents/skills/uloop-simulate-keyboard/SKILL.md index fc6596887..b38e239b8 100644 --- a/.agents/skills/uloop-simulate-keyboard/SKILL.md +++ b/.agents/skills/uloop-simulate-keyboard/SKILL.md @@ -12,10 +12,10 @@ Simulate keyboard input on Unity PlayMode: $ARGUMENTS ## Workflow 1. Ensure Unity is in PlayMode (use `uloop control-play-mode --action Play` if not) -2. Execute the appropriate `uloop simulate-keyboard` command -3. Take a screenshot to verify the result: `uloop screenshot --capture-mode rendering` -4. If the screenshot cannot prove the gameplay state, place and enable a `UnityCliLoopDebug.Break("")` marker with `uloop enable-debug-break`, run the input again, then inspect while Unity is paused -5. Report what happened +2. Execute the needed `uloop simulate-keyboard` commands +3. Inspect the result with the lightest useful evidence: runtime state, logs, or a screenshot +4. If exact-frame proof would reduce uncertainty, treat Pause Point inspection as an optional follow-up using the section below +5. Report what happened and which evidence was used ## Tool Reference @@ -39,17 +39,19 @@ uloop simulate-keyboard --action --key [options] | `KeyDown` | KeyDown only (held until KeyUp) | Start continuous movement, hold sprint | | `KeyUp` | KeyUp only (release held key) | Stop movement, release sprint | -Use `Press` for edge-triggered gameplay code such as `Keyboard.current.spaceKey.wasPressedThisFrame`. +Use `Press` for edge-triggered keyboard code such as `Keyboard.current.spaceKey.wasPressedThisFrame`. `KeyDown` emits one initial press edge, then only keeps the key held. It does not keep `wasPressedThisFrame` true while the key remains held. -If a successful `Press` or `KeyDown` leaves `Keyboard.current..isPressed` true but the game state does not change, do not immediately rewrite the user's gameplay code to `isPressed`. First verify that the gameplay component is active during the command, that it polls input in the configured Input System update phase, and that a missed `KeyDown` edge is followed by `KeyUp` before retrying. +If a successful `Press` or `KeyDown` leaves `Keyboard.current..isPressed` true but runtime state does not change, do not immediately rewrite the user's runtime code to `isPressed`. First verify that the target component is active during the command, that it polls input in the configured Input System update phase, and that a missed `KeyDown` edge is followed by `KeyUp` before retrying. Use `KeyDown` / `KeyUp` when the scenario intentionally needs a held key. -### Debug Break verification +### Pause Point Inspection (Standard for E2E) -- Use `UnityCliLoopDebug.Break("")` when a screenshot cannot prove that the keyboard input changed gameplay state, such as jump, sprint, or interaction. -- Put the marker at a natural state transition after the game consumed the key, such as after jump velocity is applied, not immediately after sending `simulate-keyboard`. -- If the response has `InterruptedByDebugBreak: true`, Unity is paused for inspection and the tool released its held input bookkeeping. If a `UnityCliLoopDebug.Break` marker caused the pause, `DebugBreakId` and `DebugBreakHitCount` identify it. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. -- Use distinct marker ids for strict phases, for example `jump-key-read` and `jump-velocity-applied`. +- Use `UloopPausePoint.Pause("")` with `uloop-wait-for-pause-point` as the standard frame proof when this input drives a state transition you are verifying. Final logs, screenshots, and durable state supplement the paused-frame check but do not replace it. +- Put the marker at a natural state transition after the app consumed the key, such as after a command is accepted, a state mutation is committed, an evaluation step resolves, or a dependent component is updated. Do not place it immediately after sending `simulate-keyboard`. +- If the key handler has local variables, intermediate calculations, or branch reasons that `execute-dynamic-code` cannot inspect after the fact, log just those values near `UloopPausePoint.Pause("")` and read them with `uloop-get-logs` while Unity is paused. A pause point hit proves the line was reached, not the frame-local values. +- Treat `simulate-keyboard Success=true`, generic action logs, and final durable counters as useful evidence, but not as paused-frame proof. +- If the response has `InterruptedByPausePoint: true`, Unity is paused for inspection and the tool released its held input bookkeeping. If a `UloopPausePoint.Pause` marker caused the pause, `PausePointId` and `PausePointHitCount` identify it. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. +- Use distinct marker ids for strict phases, for example `input-read`, `state-updated`, and `result-committed`. ### KeyDown/KeyUp Rules @@ -68,16 +70,16 @@ Use `KeyDown` / `KeyUp` when the scenario intentionally needs a held key. ## Examples ```bash -# One-shot key press (tap W once) +# One-shot key press uloop simulate-keyboard --action Press --key W -# Jump (tap Space) +# One-shot action key uloop simulate-keyboard --action Press --key Space -# Hold W for 2 seconds (move forward) +# Hold a key for 2 seconds uloop simulate-keyboard --action Press --key W --duration 2.0 -# Sprint forward (hold Shift + W, then release) +# Hold two keys, then release them uloop simulate-keyboard --action KeyDown --key LeftShift uloop simulate-keyboard --action KeyDown --key W uloop screenshot --capture-mode rendering @@ -92,9 +94,9 @@ Returns JSON with: - `Message` (string): Description of what happened or why it failed - `Action` (string): The `--action` value that was applied (`Press`, `KeyDown`, or `KeyUp`) - `KeyName` (string, nullable): The key that was acted on; may be `null` when the action could not resolve a key -- `InterruptedByDebugBreak` (boolean): True when Unity paused during Debug Break inspection and the input bookkeeping was safely released -- `DebugBreakId` (string, nullable): The marker id when a `UnityCliLoopDebug.Break` marker caused the interruption -- `DebugBreakHitCount` (integer, nullable): The marker hit count when a `UnityCliLoopDebug.Break` marker caused the interruption +- `InterruptedByPausePoint` (boolean): True when Unity paused during Pause Point inspection and the input bookkeeping was safely released +- `PausePointId` (string, nullable): The marker id when a `UloopPausePoint.Pause` marker caused the interruption +- `PausePointHitCount` (integer, nullable): The marker hit count when a `UloopPausePoint.Pause` marker caused the interruption ## Prerequisites diff --git a/.agents/skills/uloop-simulate-mouse-input/SKILL.md b/.agents/skills/uloop-simulate-mouse-input/SKILL.md index 78b3f0fa8..1c8dea3b8 100644 --- a/.agents/skills/uloop-simulate-mouse-input/SKILL.md +++ b/.agents/skills/uloop-simulate-mouse-input/SKILL.md @@ -1,22 +1,22 @@ --- name: uloop-simulate-mouse-input toolName: simulate-mouse-input -description: "Simulate Mouse.current input in PlayMode through Unity Input System. Use for gameplay clicks, mouse delta, or scroll; use simulate-mouse-ui for EventSystem UI elements." +description: "Simulate Mouse.current input in PlayMode through Unity Input System. Use for gameplay mouse clicks, held button input, movement delta, or scroll. Use simulate-mouse-ui for UI." context: fork --- # Task -Simulate mouse input via Input System in Unity PlayMode: $ARGUMENTS +Simulate mouse input via Input System in Unity PlayMode. ## Workflow 1. Ensure Unity is in PlayMode (use `uloop control-play-mode --action Play` if not) 2. For Click/LongPress: determine the target screen position (use `uloop screenshot` to find coordinates) -3. Execute the appropriate `uloop simulate-mouse-input` command -4. Take a screenshot to verify the result: `uloop screenshot --capture-mode rendering` -5. If the screenshot cannot prove the gameplay state, place and enable a `UnityCliLoopDebug.Break("")` marker with `uloop enable-debug-break`, run the input again, then inspect while Unity is paused -6. Report what happened +3. Execute the needed `uloop simulate-mouse-input` commands +4. Inspect the result with the lightest useful evidence: runtime state, logs, or a screenshot +5. When this input verifies a state transition, use Pause Point inspection from the section below as the standard frame proof +6. Report what happened and which evidence was used ## Tool Reference @@ -42,18 +42,20 @@ uloop simulate-mouse-input --action [options] | Action | What it injects | Description | |--------|----------------|-------------| -| `Click` | Mouse.current button press → release | Inject a button click so game logic detects `wasPressedThisFrame` | +| `Click` | Mouse.current button press → release | Inject a button click so runtime logic detects `wasPressedThisFrame` | | `LongPress` | Mouse.current button press → hold → release | Hold a button for `--duration` seconds | -| `MoveDelta` | Mouse.current.delta | Inject mouse movement delta one-shot (e.g. for FPS camera look) | +| `MoveDelta` | Mouse.current.delta | Inject mouse movement delta one-shot | | `SmoothDelta` | Mouse.current.delta (per-frame) | Inject mouse delta smoothly over `--duration` seconds (human-like camera pan) | -| `Scroll` | Mouse.current.scroll | Inject scroll wheel input (e.g. for hotbar or zoom) | +| `Scroll` | Mouse.current.scroll | Inject scroll wheel input | -### Debug Break verification +### Pause Point Inspection (Standard for E2E) -- Use `UnityCliLoopDebug.Break("")` when a screenshot cannot prove that mouse input changed gameplay state, such as block hit, camera turn, or item placement. -- Put the marker at a natural state transition after the game consumed the mouse input, such as after a raycast hit, damage application, placement, or camera rotation update, not immediately after sending `simulate-mouse-input`. -- If the response has `InterruptedByDebugBreak: true`, Unity is paused for inspection and the tool released its held input bookkeeping. If a `UnityCliLoopDebug.Break` marker caused the pause, `DebugBreakId` and `DebugBreakHitCount` identify it. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. -- Use distinct marker ids for strict phases, for example `block-raycast-hit` and `block-damage-applied`. +- Use `UloopPausePoint.Pause("")` with `uloop-wait-for-pause-point` as the standard frame proof when this input drives a state transition you are verifying. Final logs, screenshots, and durable state supplement the paused-frame check but do not replace it. +- Place the pause point at a natural state transition after the app consumed the mouse input, such as after a command is accepted, a state mutation is committed, an evaluation step resolves, a tracked value changes, or a dependent component is updated. Do not place it immediately after sending `simulate-mouse-input`. +- If the mouse handler has local variables, intermediate calculations, or branch reasons that `execute-dynamic-code` cannot inspect after the fact, log just those values near `UloopPausePoint.Pause("")` and read them with `uloop-get-logs` while Unity is paused. A pause point hit proves the line was reached, not the frame-local values. +- If the response has `InterruptedByPausePoint: true`, Unity is paused for inspection and the tool released its held input bookkeeping. `PausePointId` and `PausePointHitCount` identify the pause point that paused Unity. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. +- Use distinct ids for strict phases, for example `input-read`, `state-updated`, and `result-committed`. +- Remove temporary pause-point/log instrumentation before final validation when it was added only for inspection. ### Global Options (optional) @@ -67,31 +69,31 @@ uloop simulate-mouse-input --action [options] | Scenario | Tool | |----------|------| | Click a Unity UI Button (IPointerClickHandler) | `simulate-mouse-ui` | -| Destroy a block in Minecraft (reads `Mouse.current.leftButton`) | `simulate-mouse-input` when the project uses the New Input System | -| Place a block with right-click | `simulate-mouse-input --button Right` when the project uses the New Input System | +| Runtime logic reads `Mouse.current.leftButton` | `simulate-mouse-input` when the project uses the New Input System | +| Runtime logic reads right-click | `simulate-mouse-input --button Right` when the project uses the New Input System | | Drag a UI slider | `simulate-mouse-ui --action Drag` | -| Look around with mouse (FPS camera) | `simulate-mouse-input --action MoveDelta` when the project uses the New Input System | -| Scroll hotbar slots | `simulate-mouse-input --action Scroll` when the project uses the New Input System | +| Runtime logic reads `Mouse.current.delta` | `simulate-mouse-input --action MoveDelta` when the project uses the New Input System | +| Runtime logic reads `Mouse.current.scroll` | `simulate-mouse-input --action Scroll` when the project uses the New Input System | ## Examples ```bash -# Left-click at screen center (for game logic) +# Left-click at screen center for runtime input uloop simulate-mouse-input --action Click --x 400 --y 300 -# Right-click at screen center (e.g. place block) +# Right-click at screen center uloop simulate-mouse-input --action Click --x 400 --y 300 --button Right -# Hold left-click for 2 seconds (e.g. mine block) +# Hold left-click for 2 seconds uloop simulate-mouse-input --action LongPress --x 400 --y 300 --duration 2.0 -# Look right (FPS camera) +# Send a one-shot mouse delta uloop simulate-mouse-input --action MoveDelta --delta-x 100 --delta-y 0 -# Scroll up (e.g. previous hotbar slot) +# Scroll up uloop simulate-mouse-input --action Scroll --scroll-y 120 -# Scroll down (e.g. next hotbar slot) +# Scroll down uloop simulate-mouse-input --action Scroll --scroll-y -120 # Smooth camera pan right over 0.5 seconds @@ -114,8 +116,8 @@ Returns JSON with: - `Button`: Which button was used (nullable string; populated for `Click` / `LongPress`, null otherwise) - `PositionX`: Target X coordinate (nullable float; populated for `Click` / `LongPress`) - `PositionY`: Target Y coordinate (nullable float; populated for `Click` / `LongPress`) -- `InterruptedByDebugBreak`: True when Unity paused during Debug Break inspection and the input bookkeeping was safely released -- `DebugBreakId`: The marker id when a `UnityCliLoopDebug.Break` marker caused the interruption -- `DebugBreakHitCount`: The marker hit count when a `UnityCliLoopDebug.Break` marker caused the interruption +- `InterruptedByPausePoint`: True when Unity paused during Pause Point inspection and the input bookkeeping was safely released +- `PausePointId`: The id from `UloopPausePoint.Pause("")` when it caused the interruption +- `PausePointHitCount`: The hit count for that `UloopPausePoint.Pause("")` -There is no `DeltaX`, `DeltaY`, `ScrollX`, `ScrollY`, `Duration`, or hit-element field in the response — only the issued action, button, target position, and Debug Break interruption state are echoed back. Verify visual outcome with a follow-up screenshot. +There is no `DeltaX`, `DeltaY`, `ScrollX`, `ScrollY`, `Duration`, or hit-element field in the response — only the issued action, button, target position, and Pause Point interruption state are echoed back. Verify visual outcome with a follow-up screenshot. diff --git a/.agents/skills/uloop-simulate-mouse-ui/SKILL.md b/.agents/skills/uloop-simulate-mouse-ui/SKILL.md index 54e34d693..6d64aa1b1 100644 --- a/.agents/skills/uloop-simulate-mouse-ui/SKILL.md +++ b/.agents/skills/uloop-simulate-mouse-ui/SKILL.md @@ -7,16 +7,17 @@ context: fork # Task -Simulate mouse interaction on Unity PlayMode UI: $ARGUMENTS +Simulate mouse interaction on Unity PlayMode UI. ## Workflow 1. Ensure Unity is in PlayMode (use `uloop control-play-mode --action Play` if not) 2. Get UI element info: `uloop screenshot --capture-mode rendering --annotate-elements --elements-only` 3. Use the `AnnotatedElements` array to find the target element by `Label`, `Name`, or `Path` (A=frontmost, B=next, ...). Use `Interaction` to distinguish click targets from drag/drop/text targets, then use `SimX`/`SimY` directly as `--x`/`--y` coordinates. -4. Execute the appropriate `uloop simulate-mouse-ui` command -5. Take a screenshot to verify the result: `uloop screenshot --capture-mode rendering --annotate-elements` -6. Report what happened +4. Execute the needed `uloop simulate-mouse-ui` commands +5. Inspect the result with the lightest useful evidence: runtime state, logs, or a screenshot +6. When this UI input verifies a state transition, use Pause Point inspection from the section below as the standard frame proof +7. Report what happened and which evidence was used ## Tool Reference @@ -75,6 +76,15 @@ uloop simulate-mouse-ui --action --x --y [options] - `--bypass-raycast` still uses coordinates for pointer event positions, but chooses the clicked, long-pressed, or dragged GameObject by `--target-path` - If `--target-path` or `--drop-target-path` matches multiple active GameObjects, the command fails instead of choosing an arbitrary duplicate +## Pause Point Inspection (Standard for E2E) + +- Use `UloopPausePoint.Pause("")` with `uloop-wait-for-pause-point` as the standard frame proof when this input drives a state transition you are verifying. Final logs, screenshots, and durable state supplement the paused-frame check but do not replace it. +- Place the pause point at a natural transition after the app consumed the UI event, such as after a command is accepted, a state mutation is committed, a tracked value changes, a UI/domain state syncs, or a success/failure/end condition is entered. +- If the UI handler has local variables, intermediate calculations, or branch reasons that `execute-dynamic-code` cannot inspect after the fact, log just those values near `UloopPausePoint.Pause("")` and read them with `uloop-get-logs` while Unity is paused. A pause point hit proves the line was reached, not the frame-local values. +- Treat `simulate-mouse-ui Success=true`, generic action logs, and final durable counters as useful evidence, not paused-frame proof. +- If a `UloopPausePoint.Pause` pauses Unity, inspect with `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. +- Remove temporary pause-point/log instrumentation before final validation when it was added only for inspection. + ## Examples ```bash @@ -114,7 +124,7 @@ uloop simulate-mouse-ui --action DragEnd --x 600 --y 300 - Unity must be in **PlayMode** - Target scene must have an **EventSystem** GameObject - UI elements must have a **GraphicRaycaster** on their Canvas -- If you need gameplay mouse input rather than UI pointer events, `simulate-mouse-input` assumes the project uses the New Input System; otherwise prefer `execute-dynamic-code` +- If you need runtime mouse input rather than UI pointer events, `simulate-mouse-input` assumes the project uses the New Input System; otherwise prefer `execute-dynamic-code` ## Output diff --git a/.agents/skills/uloop-wait-for-debug-break/SKILL.md b/.agents/skills/uloop-wait-for-debug-break/SKILL.md deleted file mode 100644 index 1af3de1e2..000000000 --- a/.agents/skills/uloop-wait-for-debug-break/SKILL.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -name: uloop-wait-for-debug-break -description: "Use this for Unity PlayMode/E2E checks when simulated input or gameplay events cause a state transition that a screenshot, durable state, or specific value log cannot prove. Pause at the natural transition point after the input/event is consumed and inspect the paused frame. Do not use simulate-* Success=true, generic action logs, sleeps/retries, testing-only counters, or Time.timeScale changes as substitutes for debug-break evidence of transient transitions." ---- - -## Workflow - -1. Add a marker at the state you want to inspect: - -```csharp -using io.github.hatayama.UnityCliLoop.Runtime; - -UnityCliLoopDebug.Break("player-jumped"); -``` - -2. Compile the project. -3. Enable the marker before triggering the target code path: - -```bash -uloop enable-debug-break --id player-jumped --timeout-seconds 30 -``` - -4. Check marker state if needed: - -```bash -uloop debug-break-status --id player-jumped -``` - -5. Trigger the behavior with `simulate-keyboard`, `simulate-mouse-input`, UI interaction, dynamic code, or similar commands. -6. Wait for the marker: - -```bash -uloop wait-for-debug-break --id player-jumped --timeout-seconds 30 -``` - -If this command times out, the marker line was not reached while the command waited. Inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, gameplay conditions not being met, an id mismatch, or Unity already being paused. `elapsedSinceEnabledMilliseconds` is measured from `enable-debug-break`, not from `wait-for-debug-break`. - -7. While Unity is paused, inspect state with `uloop get-logs`, `uloop get-hierarchy`, `uloop find-game-objects`, screenshots, or `uloop execute-dynamic-code`. -8. Clear the marker if you stop waiting: - -```bash -uloop clear-debug-break --id player-jumped -``` - -## Marker Placement - -- Prefer natural gameplay points or state-transition points after input has been consumed, such as after jump velocity or state changes, physics contact, or damage application. -- For frame-specific bugs, place the marker on the suspicious state branch or immediately after the state mutation you need to freeze. -- To avoid Domain Reload loss or tool Busy states, enable markers after Play Mode is running, and prefer checkpoints reached after the triggering input command can return. -- Avoid placing the marker immediately after issuing simulated input unless that exact input handling line is the state you need to inspect. Immediate markers can interrupt the input command before the resulting gameplay state settles. -- Use separate ids for strict phases, for example `jump-input-read`, `jump-velocity-applied`, and `jump-landed`, instead of reusing one broad marker. - -## Safety - -- Code in a custom asmdef must reference `UnityCLILoop.PausePoints.Runtime` to use `UnityCliLoopDebug.Break`. -- Do not pass side-effect expressions as the id argument. Use stable string ids. -- This feature does not collect logs or state snapshots. Use existing inspection commands after Unity pauses. -- If `enable-debug-break` warns about Domain Reload before PlayMode, the marker may be cleared when entering PlayMode. Domain Reload disabled is suitable for this workflow; otherwise enable it again after PlayMode starts. diff --git a/.agents/skills/uloop-wait-for-pause-point/SKILL.md b/.agents/skills/uloop-wait-for-pause-point/SKILL.md new file mode 100644 index 000000000..237fd01ed --- /dev/null +++ b/.agents/skills/uloop-wait-for-pause-point/SKILL.md @@ -0,0 +1,67 @@ +--- +name: uloop-wait-for-pause-point +description: "Standard paused-frame proof for Unity PlayMode/E2E gameplay verification. Whenever you verify behavior driven by simulate-* input, physics, or UI events, pause at least one representative state transition with a pause point and inspect the frozen frame like an IDE breakpoint. simulate-* Success=true, action logs, screenshots, sleeps/retries, and final durable state supplement but do not replace this paused-frame proof." +--- + +## Quick Check Template + +Use this small loop for one representative frame you care about: + +1. Put a focused log and marker at the natural transition point. Log only local/intermediate values that will be hard to inspect later: + +```csharp +using UnityEngine; +using io.github.hatayama.UnityCliLoop.Runtime; + +Debug.Log($"state-transition-applied localValue={localValue} reason={reason}"); +UloopPausePoint.Pause("state-transition-applied"); +``` + +2. Compile, enter PlayMode, then enable the marker: + +```bash +uloop enable-pause-point --id state-transition-applied --timeout-seconds 30 +``` + +3. Trigger the action with a `simulate-*` command. +4. Run `uloop wait-for-pause-point --id state-transition-applied --timeout-seconds 30`, even if the trigger command already returned `InterruptedByPausePoint=true`. +5. Before resuming, read the focused log for the same marker id: + +```bash +uloop get-logs --search-text state-transition-applied --max-count 20 +``` + +6. While Unity is still paused, capture any additional evidence with `uloop execute-dynamic-code`, `uloop get-hierarchy`, `uloop find-game-objects`, and one screenshot. +7. Clear the marker with `uloop clear-pause-point --id state-transition-applied` or stop PlayMode before moving on. + +## When To Use + +- Use this as the standard frame proof for state-changing PlayMode/E2E simulated input, physics, or UI transitions. +- Pause at least one representative transition per E2E pass, even if durable state, logs, or screenshots can later confirm the final result. +- Use this before reaching for `Time.timeScale`, sleeps, repeated polling, or after-the-fact `execute-dynamic-code`; those checks can supplement the paused-frame proof, but they are not substitutes. +- If the value you need is a method local, an intermediate calculation, or a branch reason that `execute-dynamic-code` cannot reach, add a focused `Debug.Log` immediately before the marker and read it with `get-logs` while paused. Do not count the breakpoint check complete until the matching log has been read. +- Good pause points include after input is consumed, a command is accepted, a state mutation is committed, an evaluation step resolves, a tracked value changes, a UI/domain state syncs, or a success/failure/end condition is entered. +- Treat the pause like a lightweight breakpoint for one important transition: combine nearby debug logs with paused-frame inspection to confirm the variables and component state at that point. +- Do not treat `simulate-* Success=true`, generic action logs, sleeps/retries, testing-only counters, or `Time.timeScale` changes as paused-frame proof. +- Skip this only for ordinary persistent-state checks when you are not validating simulated input delivery, event ordering, or transition-frame fidelity. + +## Timeout Checks + +If this command times out, the marker line was not reached while the command waited. Inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, runtime conditions not being met, an id mismatch, or Unity already being paused. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. + +Use `uloop pause-point-status --id state-transition-applied` only when you need to confirm the marker is armed or inspect the current hit state. Add focused debug logs before the marker when local variables must be captured. + +## Marker Placement + +- Prefer natural runtime points after input has been consumed, such as after a command is accepted, a state value changes, an evaluation step resolves, or a dependent component is updated. +- For frame-specific bugs, place the marker on the suspicious state branch or immediately after the state mutation you need to freeze. +- To avoid Domain Reload loss or tool Busy states, enable markers after Play Mode is running, and prefer checkpoints reached after the triggering input command can return. +- Avoid placing the marker immediately after issuing simulated input unless that exact input handling line is the state you need to inspect. Immediate markers can interrupt the input command before the resulting runtime state settles. +- Use separate ids for strict phases, for example `input-read`, `state-updated`, and `result-committed`, instead of reusing one broad marker. + +## Safety + +- Code in a custom asmdef must reference `UnityCLILoop.PausePoints.Runtime` to use `UloopPausePoint.Pause`. +- Do not pass side-effect expressions as the id argument. Use stable string ids. +- This feature does not collect logs or state snapshots. Use existing inspection commands after Unity pauses. +- If `enable-pause-point` warns about Domain Reload before PlayMode, the marker may be cleared when entering PlayMode. Domain Reload disabled is suitable for this workflow; otherwise enable it again after PlayMode starts. diff --git a/.claude/skills/uloop-execute-dynamic-code/SKILL.md b/.claude/skills/uloop-execute-dynamic-code/SKILL.md index d29fcdc9c..225443be9 100644 --- a/.claude/skills/uloop-execute-dynamic-code/SKILL.md +++ b/.claude/skills/uloop-execute-dynamic-code/SKILL.md @@ -1,7 +1,7 @@ --- name: uloop-execute-dynamic-code toolName: execute-dynamic-code -description: "Execute C# with Unity APIs when existing uloop tools cannot inspect or edit enough. Use for scene, prefab, SerializedObject, AssetDatabase refresh/.meta generation, menu, or PlayMode automation." +description: "Execute C# with Unity APIs when existing uloop tools cannot inspect or edit enough. Use for reachable scene/component state, scene/prefab/menu automation, and PlayMode checks" context: fork --- @@ -11,6 +11,8 @@ Execute the following request using `uloop execute-dynamic-code`: $ARGUMENTS For basic selected GameObject discovery or property inspection, use `find-game-objects --search-mode Selected` before this tool. Use this tool after the built-in inspection tools are not enough or when you need to modify Unity state. +This tool can inspect reachable Unity state, such as GameObjects, components, public properties, static values, and method results. It cannot directly read local variables or intermediate calculations inside an already-running method. When those values matter, add a focused `Debug.Log` immediately before `UloopPausePoint.Pause("")`, then run `get-logs --search-text ` while Unity is paused. Do not replace that log read with execute-dynamic-code. + ## Workflow 1. Read the relevant reference file(s) from the Code Examples section below diff --git a/.claude/skills/uloop-get-logs/SKILL.md b/.claude/skills/uloop-get-logs/SKILL.md index a74e55532..dd10950d4 100644 --- a/.claude/skills/uloop-get-logs/SKILL.md +++ b/.claude/skills/uloop-get-logs/SKILL.md @@ -1,13 +1,15 @@ --- name: uloop-get-logs toolName: get-logs -description: "Read current Unity Console entries from a running Editor. Use during bug investigation after compile, tests, PlayMode, or dynamic code to inspect logs, warnings, errors, and stack traces." +description: "Read current Unity Console entries from a running Editor. Use during bug investigation after compile, tests, PlayMode, dynamic code, or immediately after `uloop-wait-for-pause-point`." --- # uloop get-logs Retrieve logs from Unity Console. +When a pause-point marker pauses Unity and the value you need is a method local, intermediate calculation, or branch reason, read the focused `Debug.Log` entry added immediately before the marker before resuming PlayMode. Use `--search-text ` so the marker and its log are checked as one breakpoint-style proof. + ## Usage ```bash diff --git a/.claude/skills/uloop-simulate-keyboard/SKILL.md b/.claude/skills/uloop-simulate-keyboard/SKILL.md index fc6596887..b38e239b8 100644 --- a/.claude/skills/uloop-simulate-keyboard/SKILL.md +++ b/.claude/skills/uloop-simulate-keyboard/SKILL.md @@ -12,10 +12,10 @@ Simulate keyboard input on Unity PlayMode: $ARGUMENTS ## Workflow 1. Ensure Unity is in PlayMode (use `uloop control-play-mode --action Play` if not) -2. Execute the appropriate `uloop simulate-keyboard` command -3. Take a screenshot to verify the result: `uloop screenshot --capture-mode rendering` -4. If the screenshot cannot prove the gameplay state, place and enable a `UnityCliLoopDebug.Break("")` marker with `uloop enable-debug-break`, run the input again, then inspect while Unity is paused -5. Report what happened +2. Execute the needed `uloop simulate-keyboard` commands +3. Inspect the result with the lightest useful evidence: runtime state, logs, or a screenshot +4. If exact-frame proof would reduce uncertainty, treat Pause Point inspection as an optional follow-up using the section below +5. Report what happened and which evidence was used ## Tool Reference @@ -39,17 +39,19 @@ uloop simulate-keyboard --action --key [options] | `KeyDown` | KeyDown only (held until KeyUp) | Start continuous movement, hold sprint | | `KeyUp` | KeyUp only (release held key) | Stop movement, release sprint | -Use `Press` for edge-triggered gameplay code such as `Keyboard.current.spaceKey.wasPressedThisFrame`. +Use `Press` for edge-triggered keyboard code such as `Keyboard.current.spaceKey.wasPressedThisFrame`. `KeyDown` emits one initial press edge, then only keeps the key held. It does not keep `wasPressedThisFrame` true while the key remains held. -If a successful `Press` or `KeyDown` leaves `Keyboard.current..isPressed` true but the game state does not change, do not immediately rewrite the user's gameplay code to `isPressed`. First verify that the gameplay component is active during the command, that it polls input in the configured Input System update phase, and that a missed `KeyDown` edge is followed by `KeyUp` before retrying. +If a successful `Press` or `KeyDown` leaves `Keyboard.current..isPressed` true but runtime state does not change, do not immediately rewrite the user's runtime code to `isPressed`. First verify that the target component is active during the command, that it polls input in the configured Input System update phase, and that a missed `KeyDown` edge is followed by `KeyUp` before retrying. Use `KeyDown` / `KeyUp` when the scenario intentionally needs a held key. -### Debug Break verification +### Pause Point Inspection (Standard for E2E) -- Use `UnityCliLoopDebug.Break("")` when a screenshot cannot prove that the keyboard input changed gameplay state, such as jump, sprint, or interaction. -- Put the marker at a natural state transition after the game consumed the key, such as after jump velocity is applied, not immediately after sending `simulate-keyboard`. -- If the response has `InterruptedByDebugBreak: true`, Unity is paused for inspection and the tool released its held input bookkeeping. If a `UnityCliLoopDebug.Break` marker caused the pause, `DebugBreakId` and `DebugBreakHitCount` identify it. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. -- Use distinct marker ids for strict phases, for example `jump-key-read` and `jump-velocity-applied`. +- Use `UloopPausePoint.Pause("")` with `uloop-wait-for-pause-point` as the standard frame proof when this input drives a state transition you are verifying. Final logs, screenshots, and durable state supplement the paused-frame check but do not replace it. +- Put the marker at a natural state transition after the app consumed the key, such as after a command is accepted, a state mutation is committed, an evaluation step resolves, or a dependent component is updated. Do not place it immediately after sending `simulate-keyboard`. +- If the key handler has local variables, intermediate calculations, or branch reasons that `execute-dynamic-code` cannot inspect after the fact, log just those values near `UloopPausePoint.Pause("")` and read them with `uloop-get-logs` while Unity is paused. A pause point hit proves the line was reached, not the frame-local values. +- Treat `simulate-keyboard Success=true`, generic action logs, and final durable counters as useful evidence, but not as paused-frame proof. +- If the response has `InterruptedByPausePoint: true`, Unity is paused for inspection and the tool released its held input bookkeeping. If a `UloopPausePoint.Pause` marker caused the pause, `PausePointId` and `PausePointHitCount` identify it. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. +- Use distinct marker ids for strict phases, for example `input-read`, `state-updated`, and `result-committed`. ### KeyDown/KeyUp Rules @@ -68,16 +70,16 @@ Use `KeyDown` / `KeyUp` when the scenario intentionally needs a held key. ## Examples ```bash -# One-shot key press (tap W once) +# One-shot key press uloop simulate-keyboard --action Press --key W -# Jump (tap Space) +# One-shot action key uloop simulate-keyboard --action Press --key Space -# Hold W for 2 seconds (move forward) +# Hold a key for 2 seconds uloop simulate-keyboard --action Press --key W --duration 2.0 -# Sprint forward (hold Shift + W, then release) +# Hold two keys, then release them uloop simulate-keyboard --action KeyDown --key LeftShift uloop simulate-keyboard --action KeyDown --key W uloop screenshot --capture-mode rendering @@ -92,9 +94,9 @@ Returns JSON with: - `Message` (string): Description of what happened or why it failed - `Action` (string): The `--action` value that was applied (`Press`, `KeyDown`, or `KeyUp`) - `KeyName` (string, nullable): The key that was acted on; may be `null` when the action could not resolve a key -- `InterruptedByDebugBreak` (boolean): True when Unity paused during Debug Break inspection and the input bookkeeping was safely released -- `DebugBreakId` (string, nullable): The marker id when a `UnityCliLoopDebug.Break` marker caused the interruption -- `DebugBreakHitCount` (integer, nullable): The marker hit count when a `UnityCliLoopDebug.Break` marker caused the interruption +- `InterruptedByPausePoint` (boolean): True when Unity paused during Pause Point inspection and the input bookkeeping was safely released +- `PausePointId` (string, nullable): The marker id when a `UloopPausePoint.Pause` marker caused the interruption +- `PausePointHitCount` (integer, nullable): The marker hit count when a `UloopPausePoint.Pause` marker caused the interruption ## Prerequisites diff --git a/.claude/skills/uloop-simulate-mouse-input/SKILL.md b/.claude/skills/uloop-simulate-mouse-input/SKILL.md index 78b3f0fa8..1c8dea3b8 100644 --- a/.claude/skills/uloop-simulate-mouse-input/SKILL.md +++ b/.claude/skills/uloop-simulate-mouse-input/SKILL.md @@ -1,22 +1,22 @@ --- name: uloop-simulate-mouse-input toolName: simulate-mouse-input -description: "Simulate Mouse.current input in PlayMode through Unity Input System. Use for gameplay clicks, mouse delta, or scroll; use simulate-mouse-ui for EventSystem UI elements." +description: "Simulate Mouse.current input in PlayMode through Unity Input System. Use for gameplay mouse clicks, held button input, movement delta, or scroll. Use simulate-mouse-ui for UI." context: fork --- # Task -Simulate mouse input via Input System in Unity PlayMode: $ARGUMENTS +Simulate mouse input via Input System in Unity PlayMode. ## Workflow 1. Ensure Unity is in PlayMode (use `uloop control-play-mode --action Play` if not) 2. For Click/LongPress: determine the target screen position (use `uloop screenshot` to find coordinates) -3. Execute the appropriate `uloop simulate-mouse-input` command -4. Take a screenshot to verify the result: `uloop screenshot --capture-mode rendering` -5. If the screenshot cannot prove the gameplay state, place and enable a `UnityCliLoopDebug.Break("")` marker with `uloop enable-debug-break`, run the input again, then inspect while Unity is paused -6. Report what happened +3. Execute the needed `uloop simulate-mouse-input` commands +4. Inspect the result with the lightest useful evidence: runtime state, logs, or a screenshot +5. When this input verifies a state transition, use Pause Point inspection from the section below as the standard frame proof +6. Report what happened and which evidence was used ## Tool Reference @@ -42,18 +42,20 @@ uloop simulate-mouse-input --action [options] | Action | What it injects | Description | |--------|----------------|-------------| -| `Click` | Mouse.current button press → release | Inject a button click so game logic detects `wasPressedThisFrame` | +| `Click` | Mouse.current button press → release | Inject a button click so runtime logic detects `wasPressedThisFrame` | | `LongPress` | Mouse.current button press → hold → release | Hold a button for `--duration` seconds | -| `MoveDelta` | Mouse.current.delta | Inject mouse movement delta one-shot (e.g. for FPS camera look) | +| `MoveDelta` | Mouse.current.delta | Inject mouse movement delta one-shot | | `SmoothDelta` | Mouse.current.delta (per-frame) | Inject mouse delta smoothly over `--duration` seconds (human-like camera pan) | -| `Scroll` | Mouse.current.scroll | Inject scroll wheel input (e.g. for hotbar or zoom) | +| `Scroll` | Mouse.current.scroll | Inject scroll wheel input | -### Debug Break verification +### Pause Point Inspection (Standard for E2E) -- Use `UnityCliLoopDebug.Break("")` when a screenshot cannot prove that mouse input changed gameplay state, such as block hit, camera turn, or item placement. -- Put the marker at a natural state transition after the game consumed the mouse input, such as after a raycast hit, damage application, placement, or camera rotation update, not immediately after sending `simulate-mouse-input`. -- If the response has `InterruptedByDebugBreak: true`, Unity is paused for inspection and the tool released its held input bookkeeping. If a `UnityCliLoopDebug.Break` marker caused the pause, `DebugBreakId` and `DebugBreakHitCount` identify it. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. -- Use distinct marker ids for strict phases, for example `block-raycast-hit` and `block-damage-applied`. +- Use `UloopPausePoint.Pause("")` with `uloop-wait-for-pause-point` as the standard frame proof when this input drives a state transition you are verifying. Final logs, screenshots, and durable state supplement the paused-frame check but do not replace it. +- Place the pause point at a natural state transition after the app consumed the mouse input, such as after a command is accepted, a state mutation is committed, an evaluation step resolves, a tracked value changes, or a dependent component is updated. Do not place it immediately after sending `simulate-mouse-input`. +- If the mouse handler has local variables, intermediate calculations, or branch reasons that `execute-dynamic-code` cannot inspect after the fact, log just those values near `UloopPausePoint.Pause("")` and read them with `uloop-get-logs` while Unity is paused. A pause point hit proves the line was reached, not the frame-local values. +- If the response has `InterruptedByPausePoint: true`, Unity is paused for inspection and the tool released its held input bookkeeping. `PausePointId` and `PausePointHitCount` identify the pause point that paused Unity. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. +- Use distinct ids for strict phases, for example `input-read`, `state-updated`, and `result-committed`. +- Remove temporary pause-point/log instrumentation before final validation when it was added only for inspection. ### Global Options (optional) @@ -67,31 +69,31 @@ uloop simulate-mouse-input --action [options] | Scenario | Tool | |----------|------| | Click a Unity UI Button (IPointerClickHandler) | `simulate-mouse-ui` | -| Destroy a block in Minecraft (reads `Mouse.current.leftButton`) | `simulate-mouse-input` when the project uses the New Input System | -| Place a block with right-click | `simulate-mouse-input --button Right` when the project uses the New Input System | +| Runtime logic reads `Mouse.current.leftButton` | `simulate-mouse-input` when the project uses the New Input System | +| Runtime logic reads right-click | `simulate-mouse-input --button Right` when the project uses the New Input System | | Drag a UI slider | `simulate-mouse-ui --action Drag` | -| Look around with mouse (FPS camera) | `simulate-mouse-input --action MoveDelta` when the project uses the New Input System | -| Scroll hotbar slots | `simulate-mouse-input --action Scroll` when the project uses the New Input System | +| Runtime logic reads `Mouse.current.delta` | `simulate-mouse-input --action MoveDelta` when the project uses the New Input System | +| Runtime logic reads `Mouse.current.scroll` | `simulate-mouse-input --action Scroll` when the project uses the New Input System | ## Examples ```bash -# Left-click at screen center (for game logic) +# Left-click at screen center for runtime input uloop simulate-mouse-input --action Click --x 400 --y 300 -# Right-click at screen center (e.g. place block) +# Right-click at screen center uloop simulate-mouse-input --action Click --x 400 --y 300 --button Right -# Hold left-click for 2 seconds (e.g. mine block) +# Hold left-click for 2 seconds uloop simulate-mouse-input --action LongPress --x 400 --y 300 --duration 2.0 -# Look right (FPS camera) +# Send a one-shot mouse delta uloop simulate-mouse-input --action MoveDelta --delta-x 100 --delta-y 0 -# Scroll up (e.g. previous hotbar slot) +# Scroll up uloop simulate-mouse-input --action Scroll --scroll-y 120 -# Scroll down (e.g. next hotbar slot) +# Scroll down uloop simulate-mouse-input --action Scroll --scroll-y -120 # Smooth camera pan right over 0.5 seconds @@ -114,8 +116,8 @@ Returns JSON with: - `Button`: Which button was used (nullable string; populated for `Click` / `LongPress`, null otherwise) - `PositionX`: Target X coordinate (nullable float; populated for `Click` / `LongPress`) - `PositionY`: Target Y coordinate (nullable float; populated for `Click` / `LongPress`) -- `InterruptedByDebugBreak`: True when Unity paused during Debug Break inspection and the input bookkeeping was safely released -- `DebugBreakId`: The marker id when a `UnityCliLoopDebug.Break` marker caused the interruption -- `DebugBreakHitCount`: The marker hit count when a `UnityCliLoopDebug.Break` marker caused the interruption +- `InterruptedByPausePoint`: True when Unity paused during Pause Point inspection and the input bookkeeping was safely released +- `PausePointId`: The id from `UloopPausePoint.Pause("")` when it caused the interruption +- `PausePointHitCount`: The hit count for that `UloopPausePoint.Pause("")` -There is no `DeltaX`, `DeltaY`, `ScrollX`, `ScrollY`, `Duration`, or hit-element field in the response — only the issued action, button, target position, and Debug Break interruption state are echoed back. Verify visual outcome with a follow-up screenshot. +There is no `DeltaX`, `DeltaY`, `ScrollX`, `ScrollY`, `Duration`, or hit-element field in the response — only the issued action, button, target position, and Pause Point interruption state are echoed back. Verify visual outcome with a follow-up screenshot. diff --git a/.claude/skills/uloop-simulate-mouse-ui/SKILL.md b/.claude/skills/uloop-simulate-mouse-ui/SKILL.md index 54e34d693..6d64aa1b1 100644 --- a/.claude/skills/uloop-simulate-mouse-ui/SKILL.md +++ b/.claude/skills/uloop-simulate-mouse-ui/SKILL.md @@ -7,16 +7,17 @@ context: fork # Task -Simulate mouse interaction on Unity PlayMode UI: $ARGUMENTS +Simulate mouse interaction on Unity PlayMode UI. ## Workflow 1. Ensure Unity is in PlayMode (use `uloop control-play-mode --action Play` if not) 2. Get UI element info: `uloop screenshot --capture-mode rendering --annotate-elements --elements-only` 3. Use the `AnnotatedElements` array to find the target element by `Label`, `Name`, or `Path` (A=frontmost, B=next, ...). Use `Interaction` to distinguish click targets from drag/drop/text targets, then use `SimX`/`SimY` directly as `--x`/`--y` coordinates. -4. Execute the appropriate `uloop simulate-mouse-ui` command -5. Take a screenshot to verify the result: `uloop screenshot --capture-mode rendering --annotate-elements` -6. Report what happened +4. Execute the needed `uloop simulate-mouse-ui` commands +5. Inspect the result with the lightest useful evidence: runtime state, logs, or a screenshot +6. When this UI input verifies a state transition, use Pause Point inspection from the section below as the standard frame proof +7. Report what happened and which evidence was used ## Tool Reference @@ -75,6 +76,15 @@ uloop simulate-mouse-ui --action --x --y [options] - `--bypass-raycast` still uses coordinates for pointer event positions, but chooses the clicked, long-pressed, or dragged GameObject by `--target-path` - If `--target-path` or `--drop-target-path` matches multiple active GameObjects, the command fails instead of choosing an arbitrary duplicate +## Pause Point Inspection (Standard for E2E) + +- Use `UloopPausePoint.Pause("")` with `uloop-wait-for-pause-point` as the standard frame proof when this input drives a state transition you are verifying. Final logs, screenshots, and durable state supplement the paused-frame check but do not replace it. +- Place the pause point at a natural transition after the app consumed the UI event, such as after a command is accepted, a state mutation is committed, a tracked value changes, a UI/domain state syncs, or a success/failure/end condition is entered. +- If the UI handler has local variables, intermediate calculations, or branch reasons that `execute-dynamic-code` cannot inspect after the fact, log just those values near `UloopPausePoint.Pause("")` and read them with `uloop-get-logs` while Unity is paused. A pause point hit proves the line was reached, not the frame-local values. +- Treat `simulate-mouse-ui Success=true`, generic action logs, and final durable counters as useful evidence, not paused-frame proof. +- If a `UloopPausePoint.Pause` pauses Unity, inspect with `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. +- Remove temporary pause-point/log instrumentation before final validation when it was added only for inspection. + ## Examples ```bash @@ -114,7 +124,7 @@ uloop simulate-mouse-ui --action DragEnd --x 600 --y 300 - Unity must be in **PlayMode** - Target scene must have an **EventSystem** GameObject - UI elements must have a **GraphicRaycaster** on their Canvas -- If you need gameplay mouse input rather than UI pointer events, `simulate-mouse-input` assumes the project uses the New Input System; otherwise prefer `execute-dynamic-code` +- If you need runtime mouse input rather than UI pointer events, `simulate-mouse-input` assumes the project uses the New Input System; otherwise prefer `execute-dynamic-code` ## Output diff --git a/.claude/skills/uloop-wait-for-debug-break/SKILL.md b/.claude/skills/uloop-wait-for-debug-break/SKILL.md deleted file mode 100644 index 1af3de1e2..000000000 --- a/.claude/skills/uloop-wait-for-debug-break/SKILL.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -name: uloop-wait-for-debug-break -description: "Use this for Unity PlayMode/E2E checks when simulated input or gameplay events cause a state transition that a screenshot, durable state, or specific value log cannot prove. Pause at the natural transition point after the input/event is consumed and inspect the paused frame. Do not use simulate-* Success=true, generic action logs, sleeps/retries, testing-only counters, or Time.timeScale changes as substitutes for debug-break evidence of transient transitions." ---- - -## Workflow - -1. Add a marker at the state you want to inspect: - -```csharp -using io.github.hatayama.UnityCliLoop.Runtime; - -UnityCliLoopDebug.Break("player-jumped"); -``` - -2. Compile the project. -3. Enable the marker before triggering the target code path: - -```bash -uloop enable-debug-break --id player-jumped --timeout-seconds 30 -``` - -4. Check marker state if needed: - -```bash -uloop debug-break-status --id player-jumped -``` - -5. Trigger the behavior with `simulate-keyboard`, `simulate-mouse-input`, UI interaction, dynamic code, or similar commands. -6. Wait for the marker: - -```bash -uloop wait-for-debug-break --id player-jumped --timeout-seconds 30 -``` - -If this command times out, the marker line was not reached while the command waited. Inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, gameplay conditions not being met, an id mismatch, or Unity already being paused. `elapsedSinceEnabledMilliseconds` is measured from `enable-debug-break`, not from `wait-for-debug-break`. - -7. While Unity is paused, inspect state with `uloop get-logs`, `uloop get-hierarchy`, `uloop find-game-objects`, screenshots, or `uloop execute-dynamic-code`. -8. Clear the marker if you stop waiting: - -```bash -uloop clear-debug-break --id player-jumped -``` - -## Marker Placement - -- Prefer natural gameplay points or state-transition points after input has been consumed, such as after jump velocity or state changes, physics contact, or damage application. -- For frame-specific bugs, place the marker on the suspicious state branch or immediately after the state mutation you need to freeze. -- To avoid Domain Reload loss or tool Busy states, enable markers after Play Mode is running, and prefer checkpoints reached after the triggering input command can return. -- Avoid placing the marker immediately after issuing simulated input unless that exact input handling line is the state you need to inspect. Immediate markers can interrupt the input command before the resulting gameplay state settles. -- Use separate ids for strict phases, for example `jump-input-read`, `jump-velocity-applied`, and `jump-landed`, instead of reusing one broad marker. - -## Safety - -- Code in a custom asmdef must reference `UnityCLILoop.PausePoints.Runtime` to use `UnityCliLoopDebug.Break`. -- Do not pass side-effect expressions as the id argument. Use stable string ids. -- This feature does not collect logs or state snapshots. Use existing inspection commands after Unity pauses. -- If `enable-debug-break` warns about Domain Reload before PlayMode, the marker may be cleared when entering PlayMode. Domain Reload disabled is suitable for this workflow; otherwise enable it again after PlayMode starts. diff --git a/.claude/skills/uloop-wait-for-pause-point/SKILL.md b/.claude/skills/uloop-wait-for-pause-point/SKILL.md new file mode 100644 index 000000000..237fd01ed --- /dev/null +++ b/.claude/skills/uloop-wait-for-pause-point/SKILL.md @@ -0,0 +1,67 @@ +--- +name: uloop-wait-for-pause-point +description: "Standard paused-frame proof for Unity PlayMode/E2E gameplay verification. Whenever you verify behavior driven by simulate-* input, physics, or UI events, pause at least one representative state transition with a pause point and inspect the frozen frame like an IDE breakpoint. simulate-* Success=true, action logs, screenshots, sleeps/retries, and final durable state supplement but do not replace this paused-frame proof." +--- + +## Quick Check Template + +Use this small loop for one representative frame you care about: + +1. Put a focused log and marker at the natural transition point. Log only local/intermediate values that will be hard to inspect later: + +```csharp +using UnityEngine; +using io.github.hatayama.UnityCliLoop.Runtime; + +Debug.Log($"state-transition-applied localValue={localValue} reason={reason}"); +UloopPausePoint.Pause("state-transition-applied"); +``` + +2. Compile, enter PlayMode, then enable the marker: + +```bash +uloop enable-pause-point --id state-transition-applied --timeout-seconds 30 +``` + +3. Trigger the action with a `simulate-*` command. +4. Run `uloop wait-for-pause-point --id state-transition-applied --timeout-seconds 30`, even if the trigger command already returned `InterruptedByPausePoint=true`. +5. Before resuming, read the focused log for the same marker id: + +```bash +uloop get-logs --search-text state-transition-applied --max-count 20 +``` + +6. While Unity is still paused, capture any additional evidence with `uloop execute-dynamic-code`, `uloop get-hierarchy`, `uloop find-game-objects`, and one screenshot. +7. Clear the marker with `uloop clear-pause-point --id state-transition-applied` or stop PlayMode before moving on. + +## When To Use + +- Use this as the standard frame proof for state-changing PlayMode/E2E simulated input, physics, or UI transitions. +- Pause at least one representative transition per E2E pass, even if durable state, logs, or screenshots can later confirm the final result. +- Use this before reaching for `Time.timeScale`, sleeps, repeated polling, or after-the-fact `execute-dynamic-code`; those checks can supplement the paused-frame proof, but they are not substitutes. +- If the value you need is a method local, an intermediate calculation, or a branch reason that `execute-dynamic-code` cannot reach, add a focused `Debug.Log` immediately before the marker and read it with `get-logs` while paused. Do not count the breakpoint check complete until the matching log has been read. +- Good pause points include after input is consumed, a command is accepted, a state mutation is committed, an evaluation step resolves, a tracked value changes, a UI/domain state syncs, or a success/failure/end condition is entered. +- Treat the pause like a lightweight breakpoint for one important transition: combine nearby debug logs with paused-frame inspection to confirm the variables and component state at that point. +- Do not treat `simulate-* Success=true`, generic action logs, sleeps/retries, testing-only counters, or `Time.timeScale` changes as paused-frame proof. +- Skip this only for ordinary persistent-state checks when you are not validating simulated input delivery, event ordering, or transition-frame fidelity. + +## Timeout Checks + +If this command times out, the marker line was not reached while the command waited. Inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, runtime conditions not being met, an id mismatch, or Unity already being paused. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. + +Use `uloop pause-point-status --id state-transition-applied` only when you need to confirm the marker is armed or inspect the current hit state. Add focused debug logs before the marker when local variables must be captured. + +## Marker Placement + +- Prefer natural runtime points after input has been consumed, such as after a command is accepted, a state value changes, an evaluation step resolves, or a dependent component is updated. +- For frame-specific bugs, place the marker on the suspicious state branch or immediately after the state mutation you need to freeze. +- To avoid Domain Reload loss or tool Busy states, enable markers after Play Mode is running, and prefer checkpoints reached after the triggering input command can return. +- Avoid placing the marker immediately after issuing simulated input unless that exact input handling line is the state you need to inspect. Immediate markers can interrupt the input command before the resulting runtime state settles. +- Use separate ids for strict phases, for example `input-read`, `state-updated`, and `result-committed`, instead of reusing one broad marker. + +## Safety + +- Code in a custom asmdef must reference `UnityCLILoop.PausePoints.Runtime` to use `UloopPausePoint.Pause`. +- Do not pass side-effect expressions as the id argument. Use stable string ids. +- This feature does not collect logs or state snapshots. Use existing inspection commands after Unity pauses. +- If `enable-pause-point` warns about Domain Reload before PlayMode, the marker may be cleared when entering PlayMode. Domain Reload disabled is suitable for this workflow; otherwise enable it again after PlayMode starts. diff --git a/Assets/Tests/Editor/PausePointTests.cs b/Assets/Tests/Editor/PausePointTests.cs index 9d10457e3..515a1d3f7 100644 --- a/Assets/Tests/Editor/PausePointTests.cs +++ b/Assets/Tests/Editor/PausePointTests.cs @@ -41,10 +41,10 @@ public void TearDown() } [Test] - public void Break_WhenPausePointIsNotEnabled_DoesNotPause() + public void Pause_WhenPausePointIsNotEnabled_DoesNotPause() { // Verifies marker calls are no-op until the CLI enables the same id. - UnityCliLoopDebug.Break("jump"); + UloopPausePoint.Pause("jump"); UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("jump"); @@ -54,12 +54,12 @@ public void Break_WhenPausePointIsNotEnabled_DoesNotPause() } [Test] - public void Break_WhenPausePointIsEnabled_RecordsHitAndRequestsPause() + public void Pause_WhenPausePointIsEnabled_RecordsHitAndRequestsPause() { // Verifies an enabled marker hit records state and requests a Unity pause. UloopPausePointRegistry.Enable("jump", 30); - UnityCliLoopDebug.Break("jump"); + UloopPausePoint.Pause("jump"); UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("jump"); Assert.That(_pauseController.PauseCount, Is.EqualTo(1)); @@ -71,12 +71,12 @@ public void Break_WhenPausePointIsEnabled_RecordsHitAndRequestsPause() } [Test] - public void Break_WhenPausePointIsEnabled_StoresLatestHitSnapshot() + public void Pause_WhenPausePointIsEnabled_StoresLatestHitSnapshot() { // Verifies input interruption responses can read the latest marker hit. UloopPausePointRegistry.Enable("jump", 30); - UnityCliLoopDebug.Break("jump"); + UloopPausePoint.Pause("jump"); UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetLatestHitSnapshot(); Assert.That(snapshot, Is.Not.Null); @@ -92,7 +92,7 @@ public void GetStatus_WhenTimeoutPasses_ExpiresAndDisarms() _nowUtc = _nowUtc.AddSeconds(2); UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("jump"); - UnityCliLoopDebug.Break("jump"); + UloopPausePoint.Pause("jump"); Assert.That(snapshot.Status, Is.EqualTo(UloopPausePointStatus.Expired)); Assert.That(snapshot.IsEnabled, Is.False); @@ -118,7 +118,7 @@ public void Clear_WhenPausePointIsEnabled_DisablesWithoutPause() UloopPausePointRegistry.Enable("jump", 30); UloopPausePointSnapshot snapshot = UloopPausePointRegistry.Clear("jump"); - UnityCliLoopDebug.Break("jump"); + UloopPausePoint.Pause("jump"); Assert.That(snapshot.Status, Is.EqualTo(UloopPausePointStatus.Cleared)); Assert.That(snapshot.IsEnabled, Is.False); @@ -130,7 +130,7 @@ public void Enable_WhenSamePausePointWasHit_ClearsLatestHitSnapshot() { // Verifies re-enabling a marker does not leave stale hit details for input tools. UloopPausePointRegistry.Enable("jump", 30); - UnityCliLoopDebug.Break("jump"); + UloopPausePoint.Pause("jump"); UloopPausePointRegistry.Enable("jump", 30); @@ -142,7 +142,7 @@ public void ClearAll_WhenPausePointWasHit_ClearsTerminalStatus() { // Verifies bulk clear hides stale terminal hit status from future waits. UloopPausePointRegistry.Enable("jump", 30); - UnityCliLoopDebug.Break("jump"); + UloopPausePoint.Pause("jump"); UloopPausePointClearAllResult result = UloopPausePointRegistry.ClearAll(); UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("jump"); @@ -154,16 +154,16 @@ public void ClearAll_WhenPausePointWasHit_ClearsTerminalStatus() } [Test] - public void BreakMethod_WhenSourceIsScanned_UsesUnityEditorConditionalWithoutDebugBreak() + public void PauseMethod_WhenSourceIsScanned_UsesUnityEditorConditionalWithoutDebugBreak() { // Verifies the public marker follows Unity's conditional call-site removal pattern. string sourcePath = Path.Combine( Directory.GetCurrentDirectory(), - "Packages/src/Runtime/PausePoints/UnityCliLoopDebug.cs"); + "Packages/src/Runtime/PausePoints/UloopPausePoint.cs"); string source = File.ReadAllText(sourcePath); Assert.That(source, Does.Contain("[Conditional(\"UNITY_EDITOR\")]")); - Assert.That(source, Does.Contain("public static void Break(string id)")); + Assert.That(source, Does.Contain("public static void Pause(string id)")); Assert.That(source, Does.Not.Contain("Debug.Break")); } @@ -174,7 +174,7 @@ public async Task Enable_WhenPlayModeInactiveAndDomainReloadEnabled_ReturnsWarni EditorSettings.enterPlayModeOptionsEnabled = false; EditorSettings.enterPlayModeOptions = EnterPlayModeOptions.None; - PausePointResponse response = await EnableDebugBreakAsync("jump"); + PausePointResponse response = await EnablePausePointAsync("jump"); Assert.That(response.Warning, Does.Contain("Domain Reload is enabled")); Assert.That(response.Warning, Does.Contain("keep Domain Reload disabled")); @@ -187,12 +187,12 @@ public async Task Enable_WhenPlayModeInactiveAndDomainReloadDisabled_ReturnsNoWa EditorSettings.enterPlayModeOptionsEnabled = true; EditorSettings.enterPlayModeOptions = EnterPlayModeOptions.DisableDomainReload; - PausePointResponse response = await EnableDebugBreakAsync("dash"); + PausePointResponse response = await EnablePausePointAsync("dash"); Assert.That(response.Warning, Is.Empty); } - private static async Task EnableDebugBreakAsync(string id) + private static async Task EnablePausePointAsync(string id) { EnablePausePointTool tool = new(); JObject parameters = new() diff --git a/Assets/Tests/Editor/ToolSettingsUseCaseTests.cs b/Assets/Tests/Editor/ToolSettingsUseCaseTests.cs index 97c200da1..c617122e3 100644 --- a/Assets/Tests/Editor/ToolSettingsUseCaseTests.cs +++ b/Assets/Tests/Editor/ToolSettingsUseCaseTests.cs @@ -15,9 +15,9 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor public class ToolSettingsUseCaseTests { [Test] - public void TryGetToolCatalog_WhenRegistryAvailable_IncludesNativeDebugBreakCommands() + public void TryGetToolCatalog_WhenRegistryAvailable_IncludesNativePausePointCommands() { - // Verifies CLI-native debug break commands are user-toggleable built-in tools. + // Verifies CLI-native pause point commands are user-toggleable built-in tools. ToolSettingsService toolSettingsService = new(new ToolSettingsRepository()); UnityCliLoopToolRegistrarService toolRegistrarService = new( new EmptyInternalToolNameProvider(), @@ -30,8 +30,8 @@ public void TryGetToolCatalog_WhenRegistryAvailable_IncludesNativeDebugBreakComm string[] toolNames = allTools.Select(tool => tool.Name).ToArray(); Assert.That(isAvailable, Is.True); - Assert.That(toolNames, Does.Contain(UnityCliLoopConstants.COMMAND_NAME_WAIT_FOR_DEBUG_BREAK)); - Assert.That(toolNames, Does.Contain(UnityCliLoopConstants.COMMAND_NAME_DEBUG_BREAK_STATUS)); + Assert.That(toolNames, Does.Contain(UnityCliLoopConstants.COMMAND_NAME_WAIT_FOR_PAUSE_POINT)); + Assert.That(toolNames, Does.Contain(UnityCliLoopConstants.COMMAND_NAME_PAUSE_POINT_STATUS)); } } } diff --git a/Assets/Tests/Editor/UnityCliLoopToolRegistryTests.cs b/Assets/Tests/Editor/UnityCliLoopToolRegistryTests.cs index a52d6ff55..78c82559b 100644 --- a/Assets/Tests/Editor/UnityCliLoopToolRegistryTests.cs +++ b/Assets/Tests/Editor/UnityCliLoopToolRegistryTests.cs @@ -54,8 +54,8 @@ public void Constructor_WhenFirstPartyToolsUseToolAttribute_RegistersThem() Assert.That(registry.IsToolRegistered("compile"), Is.True); Assert.That(registry.IsToolRegistered("get-logs"), Is.True); - Assert.That(registry.IsToolRegistered(UnityCliLoopConstants.TOOL_NAME_ENABLE_DEBUG_BREAK), Is.True); - Assert.That(registry.IsToolRegistered(UnityCliLoopConstants.TOOL_NAME_CLEAR_DEBUG_BREAK), Is.True); + Assert.That(registry.IsToolRegistered(UnityCliLoopConstants.TOOL_NAME_ENABLE_PAUSE_POINT), Is.True); + Assert.That(registry.IsToolRegistered(UnityCliLoopConstants.TOOL_NAME_CLEAR_PAUSE_POINT), Is.True); Assert.That(registry.IsToolRegistered("execute-dynamic-code"), Is.True); Assert.That(registry.IsToolRegistered("clear-console"), Is.True); Assert.That(registry.IsToolRegistered("get-hierarchy"), Is.True); @@ -257,8 +257,8 @@ public void GetToolType_WhenPausePointComesFromFirstPartyToolsAssembly_ReturnsBu // Tests that pause point tools are bundled plugins instead of application-layer tools. UnityCliLoopToolRegistry registry = ToolRegistryTestFactory.Create(); - System.Type enableToolType = registry.GetToolType(UnityCliLoopConstants.TOOL_NAME_ENABLE_DEBUG_BREAK); - System.Type clearToolType = registry.GetToolType(UnityCliLoopConstants.TOOL_NAME_CLEAR_DEBUG_BREAK); + System.Type enableToolType = registry.GetToolType(UnityCliLoopConstants.TOOL_NAME_ENABLE_PAUSE_POINT); + System.Type clearToolType = registry.GetToolType(UnityCliLoopConstants.TOOL_NAME_CLEAR_PAUSE_POINT); Assert.That(enableToolType, Is.Not.Null); Assert.That(clearToolType, Is.Not.Null); diff --git a/Assets/Tests/PlayMode/SimulateKeyboardTests.cs b/Assets/Tests/PlayMode/SimulateKeyboardTests.cs index 49964d402..71f882b2d 100644 --- a/Assets/Tests/PlayMode/SimulateKeyboardTests.cs +++ b/Assets/Tests/PlayMode/SimulateKeyboardTests.cs @@ -118,9 +118,9 @@ public IEnumerator Press_WithDuration_Should_HoldKey() } [UnityTest] - public IEnumerator Press_WhenUnityPausesDuringObservation_Should_CompleteAsDebugBreakInterruption() + public IEnumerator Press_WhenUnityPausesDuringObservation_Should_CompleteAsPausePointInterruption() { - // Verifies that a debug-break pause releases the tool slot instead of leaving the press command busy. + // Verifies that a pause-point pause releases the tool slot instead of leaving the press command busy. yield return null; SimulateKeyboardSchema parameters = new() @@ -141,17 +141,17 @@ public IEnumerator Press_WhenUnityPausesDuringObservation_Should_CompleteAsDebug lastResponse = task.Result; Assert.IsTrue(lastResponse.Success); - Assert.IsTrue(lastResponse.InterruptedByDebugBreak); + Assert.IsTrue(lastResponse.InterruptedByPausePoint); Assert.AreEqual("Press", lastResponse.Action); Assert.AreEqual("Space", lastResponse.KeyName); - Assert.IsNull(lastResponse.DebugBreakId); - Assert.IsNull(lastResponse.DebugBreakHitCount); - Assert.IsFalse(keyboard[Key.Space].isPressed, "Debug-break interruption should release the injected key state."); - Assert.IsFalse(SimulateKeyboardOverlayState.IsActive, "Debug-break interruption should clear keyboard overlay state."); + Assert.IsNull(lastResponse.PausePointId); + Assert.IsNull(lastResponse.PausePointHitCount); + Assert.IsFalse(keyboard[Key.Space].isPressed, "Pause-point interruption should release the injected key state."); + Assert.IsFalse(SimulateKeyboardOverlayState.IsActive, "Pause-point interruption should clear keyboard overlay state."); } [UnityTest] - public IEnumerator Press_WhenDebugBreakMarkerHits_Should_ReturnMarkerDetails() + public IEnumerator Press_WhenPausePointMarkerHits_Should_ReturnMarkerDetails() { // Verifies marker-caused interruption reports the marker id and hit count. yield return null; @@ -172,16 +172,16 @@ public IEnumerator Press_WhenDebugBreakMarkerHits_Should_ReturnMarkerDetails() yield return new WaitUntil(() => keyboard[Key.Space].isPressed || task.IsCompleted); Assert.IsFalse(task.IsCompleted, "The test must pause during the press observation window."); - UnityCliLoopDebug.Break("space-press"); + UloopPausePoint.Pause("space-press"); InputSystemUpdateHelper.ConfigurePauseProviderForTests(() => true); yield return WaitForTask(task); InputSystemUpdateHelper.ResetPauseProviderForTests(); lastResponse = task.Result; Assert.IsTrue(lastResponse.Success); - Assert.IsTrue(lastResponse.InterruptedByDebugBreak); - Assert.AreEqual("space-press", lastResponse.DebugBreakId); - Assert.AreEqual(1, lastResponse.DebugBreakHitCount); + Assert.IsTrue(lastResponse.InterruptedByPausePoint); + Assert.AreEqual("space-press", lastResponse.PausePointId); + Assert.AreEqual(1, lastResponse.PausePointHitCount); Assert.IsFalse(keyboard[Key.Space].isPressed, "Marker interruption should release the injected key state."); } diff --git a/Assets/Tests/PlayMode/SimulateMouseInputTests.cs b/Assets/Tests/PlayMode/SimulateMouseInputTests.cs index fe4ec73b3..a4da0fb86 100644 --- a/Assets/Tests/PlayMode/SimulateMouseInputTests.cs +++ b/Assets/Tests/PlayMode/SimulateMouseInputTests.cs @@ -107,9 +107,9 @@ public IEnumerator Click_MiddleButton_Should_InjectMiddleClick() } [UnityTest] - public IEnumerator Click_WhenUnityPausesDuringObservation_Should_CompleteAsDebugBreakInterruption() + public IEnumerator Click_WhenUnityPausesDuringObservation_Should_CompleteAsPausePointInterruption() { - // Verifies that a debug-break pause releases the tool slot instead of leaving the click command busy. + // Verifies that a pause-point pause releases the tool slot instead of leaving the click command busy. yield return null; Task task = tool.ExecuteAsync(new JObject @@ -129,17 +129,17 @@ public IEnumerator Click_WhenUnityPausesDuringObservation_Should_CompleteAsDebug lastResponse = (SimulateMouseInputResponse)task.Result; Assert.IsTrue(lastResponse.Success); - Assert.IsTrue(lastResponse.InterruptedByDebugBreak); + Assert.IsTrue(lastResponse.InterruptedByPausePoint); Assert.AreEqual("Click", lastResponse.Action); Assert.AreEqual("Left", lastResponse.Button); - Assert.IsNull(lastResponse.DebugBreakId); - Assert.IsNull(lastResponse.DebugBreakHitCount); - Assert.IsFalse(mouse.leftButton.isPressed, "Debug-break interruption should release the injected mouse button state."); - Assert.IsFalse(SimulateMouseInputOverlayState.HasAnyActivity, "Debug-break interruption should clear mouse overlay state."); + Assert.IsNull(lastResponse.PausePointId); + Assert.IsNull(lastResponse.PausePointHitCount); + Assert.IsFalse(mouse.leftButton.isPressed, "Pause-point interruption should release the injected mouse button state."); + Assert.IsFalse(SimulateMouseInputOverlayState.HasAnyActivity, "Pause-point interruption should clear mouse overlay state."); } [UnityTest] - public IEnumerator Click_WhenDebugBreakMarkerHits_Should_ReturnMarkerDetails() + public IEnumerator Click_WhenPausePointMarkerHits_Should_ReturnMarkerDetails() { // Verifies marker-caused interruption reports the marker id and hit count. yield return null; @@ -159,16 +159,16 @@ public IEnumerator Click_WhenDebugBreakMarkerHits_Should_ReturnMarkerDetails() yield return new WaitUntil(() => mouse.leftButton.isPressed || task.IsCompleted); Assert.IsFalse(task.IsCompleted, "The test must pause during the click observation window."); - UnityCliLoopDebug.Break("left-click"); + UloopPausePoint.Pause("left-click"); InputSystemUpdateHelper.ConfigurePauseProviderForTests(() => true); yield return WaitForTask(task); InputSystemUpdateHelper.ResetPauseProviderForTests(); lastResponse = (SimulateMouseInputResponse)task.Result; Assert.IsTrue(lastResponse.Success); - Assert.IsTrue(lastResponse.InterruptedByDebugBreak); - Assert.AreEqual("left-click", lastResponse.DebugBreakId); - Assert.AreEqual(1, lastResponse.DebugBreakHitCount); + Assert.IsTrue(lastResponse.InterruptedByPausePoint); + Assert.AreEqual("left-click", lastResponse.PausePointId); + Assert.AreEqual(1, lastResponse.PausePointHitCount); Assert.IsFalse(mouse.leftButton.isPressed, "Marker interruption should release the injected mouse button state."); } @@ -262,12 +262,12 @@ public IEnumerator LongPress_Should_RestoreRunInBackground_WhenOriginallyDisable #region SmoothDelta Tests [UnityTest] - public IEnumerator SmoothDelta_WhenDebugBreakMarkerHitsDuringGameplayUpdate_Should_ReturnMarkerDetails() + public IEnumerator SmoothDelta_WhenPausePointMarkerHitsDuringGameplayUpdate_Should_ReturnMarkerDetails() { - // Verifies SmoothDelta observes gameplay-frame debug breaks before scheduling the next delta. + // Verifies SmoothDelta observes gameplay-frame pause points before scheduling the next delta. yield return null; - MouseDeltaDebugBreakObserver observer = mouseObserverGo.AddComponent(); + MouseDeltaPausePointObserver observer = mouseObserverGo.AddComponent(); observer.MarkerId = "smooth-delta"; UloopPausePointRegistry.ConfigureForTests( new FakePausePointPauseController(), @@ -296,10 +296,10 @@ public IEnumerator SmoothDelta_WhenDebugBreakMarkerHitsDuringGameplayUpdate_Shou lastResponse = (SimulateMouseInputResponse)task.Result; Assert.IsTrue(lastResponse.Success); - Assert.IsTrue(lastResponse.InterruptedByDebugBreak); + Assert.IsTrue(lastResponse.InterruptedByPausePoint); Assert.AreEqual("SmoothDelta", lastResponse.Action); - Assert.AreEqual("smooth-delta", lastResponse.DebugBreakId); - Assert.AreEqual(1, lastResponse.DebugBreakHitCount); + Assert.AreEqual("smooth-delta", lastResponse.PausePointId); + Assert.AreEqual(1, lastResponse.PausePointHitCount); } #endregion @@ -420,9 +420,9 @@ public void ResetCount() } /// - /// Test support type that hits a debug-break marker when gameplay observes mouse delta. + /// Test support type that hits a pause-point marker when gameplay observes mouse delta. /// - public class MouseDeltaDebugBreakObserver : MonoBehaviour + public class MouseDeltaPausePointObserver : MonoBehaviour { public string MarkerId { get; set; } = ""; public bool HasTriggered { get; private set; } @@ -447,7 +447,7 @@ private void Update() } HasTriggered = true; - UnityCliLoopDebug.Break(MarkerId); + UloopPausePoint.Pause(MarkerId); InputSystemUpdateHelper.ConfigurePauseProviderForTests(() => true); } } diff --git a/Packages/src/Editor/Application/UseCases/ToolSettingsUseCase.cs b/Packages/src/Editor/Application/UseCases/ToolSettingsUseCase.cs index 2e239a4ba..6536ae83b 100644 --- a/Packages/src/Editor/Application/UseCases/ToolSettingsUseCase.cs +++ b/Packages/src/Editor/Application/UseCases/ToolSettingsUseCase.cs @@ -12,11 +12,11 @@ internal sealed class ToolSettingsUseCase private static readonly ToolCatalogItem[] NativeToolCatalogItems = { new( - UnityCliLoopConstants.COMMAND_NAME_WAIT_FOR_DEBUG_BREAK, + UnityCliLoopConstants.COMMAND_NAME_WAIT_FOR_PAUSE_POINT, displayDevelopmentOnly: false, isThirdParty: false), new( - UnityCliLoopConstants.COMMAND_NAME_DEBUG_BREAK_STATUS, + UnityCliLoopConstants.COMMAND_NAME_PAUSE_POINT_STATUS, displayDevelopmentOnly: false, isThirdParty: false) }; diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md index fec283c84..237fd01ed 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -1,6 +1,6 @@ --- -name: uloop-wait-for-debug-break -description: "Standard paused-frame proof for Unity PlayMode/E2E gameplay verification. Whenever you verify behavior driven by simulate-* input, physics, or UI events, pause at least one representative state transition with a debug break and inspect the frozen frame like an IDE breakpoint. simulate-* Success=true, action logs, screenshots, sleeps/retries, and final durable state supplement but do not replace this paused-frame proof." +name: uloop-wait-for-pause-point +description: "Standard paused-frame proof for Unity PlayMode/E2E gameplay verification. Whenever you verify behavior driven by simulate-* input, physics, or UI events, pause at least one representative state transition with a pause point and inspect the frozen frame like an IDE breakpoint. simulate-* Success=true, action logs, screenshots, sleeps/retries, and final durable state supplement but do not replace this paused-frame proof." --- ## Quick Check Template @@ -14,17 +14,17 @@ using UnityEngine; using io.github.hatayama.UnityCliLoop.Runtime; Debug.Log($"state-transition-applied localValue={localValue} reason={reason}"); -UnityCliLoopDebug.Break("state-transition-applied"); +UloopPausePoint.Pause("state-transition-applied"); ``` 2. Compile, enter PlayMode, then enable the marker: ```bash -uloop enable-debug-break --id state-transition-applied --timeout-seconds 30 +uloop enable-pause-point --id state-transition-applied --timeout-seconds 30 ``` 3. Trigger the action with a `simulate-*` command. -4. Run `uloop wait-for-debug-break --id state-transition-applied --timeout-seconds 30`, even if the trigger command already returned `InterruptedByDebugBreak=true`. +4. Run `uloop wait-for-pause-point --id state-transition-applied --timeout-seconds 30`, even if the trigger command already returned `InterruptedByPausePoint=true`. 5. Before resuming, read the focused log for the same marker id: ```bash @@ -32,7 +32,7 @@ uloop get-logs --search-text state-transition-applied --max-count 20 ``` 6. While Unity is still paused, capture any additional evidence with `uloop execute-dynamic-code`, `uloop get-hierarchy`, `uloop find-game-objects`, and one screenshot. -7. Clear the marker with `uloop clear-debug-break --id state-transition-applied` or stop PlayMode before moving on. +7. Clear the marker with `uloop clear-pause-point --id state-transition-applied` or stop PlayMode before moving on. ## When To Use @@ -47,9 +47,9 @@ uloop get-logs --search-text state-transition-applied --max-count 20 ## Timeout Checks -If this command times out, the marker line was not reached while the command waited. Inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, runtime conditions not being met, an id mismatch, or Unity already being paused. `elapsedSinceEnabledMilliseconds` is measured from `enable-debug-break`, not from `wait-for-debug-break`. +If this command times out, the marker line was not reached while the command waited. Inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, runtime conditions not being met, an id mismatch, or Unity already being paused. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. -Use `uloop debug-break-status --id state-transition-applied` only when you need to confirm the marker is armed or inspect the current hit state. Add focused debug logs before the marker when local variables must be captured. +Use `uloop pause-point-status --id state-transition-applied` only when you need to confirm the marker is armed or inspect the current hit state. Add focused debug logs before the marker when local variables must be captured. ## Marker Placement @@ -61,7 +61,7 @@ Use `uloop debug-break-status --id state-transition-applied` only when you need ## Safety -- Code in a custom asmdef must reference `UnityCLILoop.PausePoints.Runtime` to use `UnityCliLoopDebug.Break`. +- Code in a custom asmdef must reference `UnityCLILoop.PausePoints.Runtime` to use `UloopPausePoint.Pause`. - Do not pass side-effect expressions as the id argument. Use stable string ids. - This feature does not collect logs or state snapshots. Use existing inspection commands after Unity pauses. -- If `enable-debug-break` warns about Domain Reload before PlayMode, the marker may be cleared when entering PlayMode. Domain Reload disabled is suitable for this workflow; otherwise enable it again after PlayMode starts. +- If `enable-pause-point` warns about Domain Reload before PlayMode, the marker may be cleared when entering PlayMode. Domain Reload disabled is suitable for this workflow; otherwise enable it again after PlayMode starts. diff --git a/Packages/src/Editor/Domain/CliConstants.cs b/Packages/src/Editor/Domain/CliConstants.cs index 299d862e6..4d96111a4 100644 --- a/Packages/src/Editor/Domain/CliConstants.cs +++ b/Packages/src/Editor/Domain/CliConstants.cs @@ -6,7 +6,7 @@ namespace io.github.hatayama.UnityCliLoop.Domain public static class CliConstants { public const string EXECUTABLE_NAME = "uloop"; - public const string MINIMUM_REQUIRED_CLI_VERSION = "3.0.0-beta.28"; + public const string MINIMUM_REQUIRED_CLI_VERSION = "3.0.0-beta.29"; public const string MINIMUM_REQUIRED_CLI_RELEASE_TAG = CLI_RELEASE_TAG_PREFIX + MINIMUM_REQUIRED_CLI_VERSION; public const string VERSION_FLAG = "--version"; public const string SHORT_VERSION_FLAG = "-v"; diff --git a/Packages/src/Editor/FirstPartyTools/Common/InputSimulation/UnityCliLoopInputSimulationTypes.cs b/Packages/src/Editor/FirstPartyTools/Common/InputSimulation/UnityCliLoopInputSimulationTypes.cs index 4bd14d6cd..831c675ac 100644 --- a/Packages/src/Editor/FirstPartyTools/Common/InputSimulation/UnityCliLoopInputSimulationTypes.cs +++ b/Packages/src/Editor/FirstPartyTools/Common/InputSimulation/UnityCliLoopInputSimulationTypes.cs @@ -95,9 +95,9 @@ public sealed class UnityCliLoopKeyboardSimulationResult public string Message { get; set; } = ""; public string Action { get; set; } = ""; public string? KeyName { get; set; } - public bool InterruptedByDebugBreak { get; set; } - public string? DebugBreakId { get; set; } - public int? DebugBreakHitCount { get; set; } + public bool InterruptedByPausePoint { get; set; } + public string? PausePointId { get; set; } + public int? PausePointHitCount { get; set; } } /// @@ -127,9 +127,9 @@ public sealed class UnityCliLoopMouseInputSimulationResult public string? Button { get; set; } public float? PositionX { get; set; } public float? PositionY { get; set; } - public bool InterruptedByDebugBreak { get; set; } - public string? DebugBreakId { get; set; } - public int? DebugBreakHitCount { get; set; } + public bool InterruptedByPausePoint { get; set; } + public string? PausePointId { get; set; } + public int? PausePointHitCount { get; set; } } /// diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/SKILL.md index bfd3f4e0a..225443be9 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/SKILL.md @@ -11,7 +11,7 @@ Execute the following request using `uloop execute-dynamic-code`: $ARGUMENTS For basic selected GameObject discovery or property inspection, use `find-game-objects --search-mode Selected` before this tool. Use this tool after the built-in inspection tools are not enough or when you need to modify Unity state. -This tool can inspect reachable Unity state, such as GameObjects, components, public properties, static values, and method results. It cannot directly read local variables or intermediate calculations inside an already-running method. When those values matter, add a focused `Debug.Log` immediately before `UnityCliLoopDebug.Break("")`, then run `get-logs --search-text ` while Unity is paused. Do not replace that log read with execute-dynamic-code. +This tool can inspect reachable Unity state, such as GameObjects, components, public properties, static values, and method results. It cannot directly read local variables or intermediate calculations inside an already-running method. When those values matter, add a focused `Debug.Log` immediately before `UloopPausePoint.Pause("")`, then run `get-logs --search-text ` while Unity is paused. Do not replace that log read with execute-dynamic-code. ## Workflow diff --git a/Packages/src/Editor/FirstPartyTools/GetLogs/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/GetLogs/Skill/SKILL.md index 2ad7bd92a..dd10950d4 100644 --- a/Packages/src/Editor/FirstPartyTools/GetLogs/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/GetLogs/Skill/SKILL.md @@ -1,14 +1,14 @@ --- name: uloop-get-logs toolName: get-logs -description: "Read current Unity Console entries from a running Editor. Use during bug investigation after compile, tests, PlayMode, dynamic code, or immediately after `uloop-wait-for-debug-break`." +description: "Read current Unity Console entries from a running Editor. Use during bug investigation after compile, tests, PlayMode, dynamic code, or immediately after `uloop-wait-for-pause-point`." --- # uloop get-logs Retrieve logs from Unity Console. -When a debug-break marker pauses Unity and the value you need is a method local, intermediate calculation, or branch reason, read the focused `Debug.Log` entry added immediately before the marker before resuming PlayMode. Use `--search-text ` so the marker and its log are checked as one breakpoint-style proof. +When a pause-point marker pauses Unity and the value you need is a method local, intermediate calculation, or branch reason, read the focused `Debug.Log` entry added immediately before the marker before resuming PlayMode. Use `--search-text ` so the marker and its log are checked as one breakpoint-style proof. ## Usage diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs index e91401299..39c6ca918 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs @@ -9,7 +9,7 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// - /// Parameters for enabling one named debug break marker. + /// Parameters for enabling one named pause point marker. /// public class EnablePausePointSchema : UnityCliLoopToolSchema { @@ -19,7 +19,7 @@ public class EnablePausePointSchema : UnityCliLoopToolSchema } /// - /// Parameters for clearing one or all debug break markers. + /// Parameters for clearing one or all pause point markers. /// public class ClearPausePointSchema : UnityCliLoopToolSchema { @@ -29,7 +29,7 @@ public class ClearPausePointSchema : UnityCliLoopToolSchema } /// - /// Response shared by debug break tool commands. + /// Response shared by pause point tool commands. /// public class PausePointResponse : UnityCliLoopToolResponse { @@ -79,18 +79,18 @@ internal static PausePointResponse FromClearAll(UloopPausePointClearAllResult re { Status = UloopPausePointStatus.Cleared, ClearedCount = result.ClearedCount, - Message = "Debug breaks cleared." + Message = "Pause points cleared." }; } } /// - /// Exposes debug break enabling as a Unity CLI Loop tool. + /// Exposes pause point enabling as a Unity CLI Loop tool. /// [UnityCliLoopTool] public class EnablePausePointTool : UnityCliLoopTool { - public override string ToolName => UnityCliLoopConstants.TOOL_NAME_ENABLE_DEBUG_BREAK; + public override string ToolName => UnityCliLoopConstants.TOOL_NAME_ENABLE_PAUSE_POINT; protected override Task ExecuteAsync(EnablePausePointSchema parameters, CancellationToken ct) { @@ -102,12 +102,12 @@ protected override Task ExecuteAsync(EnablePausePointSchema } /// - /// Exposes debug break clearing as a Unity CLI Loop tool. + /// Exposes pause point clearing as a Unity CLI Loop tool. /// [UnityCliLoopTool] public class ClearPausePointTool : UnityCliLoopTool { - public override string ToolName => UnityCliLoopConstants.TOOL_NAME_CLEAR_DEBUG_BREAK; + public override string ToolName => UnityCliLoopConstants.TOOL_NAME_CLEAR_PAUSE_POINT; protected override Task ExecuteAsync(ClearPausePointSchema parameters, CancellationToken ct) { @@ -119,7 +119,7 @@ protected override Task ExecuteAsync(ClearPausePointSchema p } /// - /// Coordinates debug break tool validation and registry updates. + /// Coordinates pause point tool validation and registry updates. /// internal sealed class PausePointUseCase { @@ -182,7 +182,7 @@ private static string CreateEnableWarning() return string.Empty; } - return "Debug break was enabled before PlayMode while Domain Reload is enabled. " + + return "Pause point was enabled before PlayMode while Domain Reload is enabled. " + "Entering PlayMode may clear this marker; keep Domain Reload disabled for this workflow or enable the marker after PlayMode starts."; } diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs index 6d5227847..759ac22b6 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs @@ -13,9 +13,9 @@ public class SimulateKeyboardResponse : UnityCliLoopToolResponse public string Message { get; set; } = ""; public string Action { get; set; } = ""; public string? KeyName { get; set; } - public bool InterruptedByDebugBreak { get; set; } - public string? DebugBreakId { get; set; } - public int? DebugBreakHitCount { get; set; } + public bool InterruptedByPausePoint { get; set; } + public string? PausePointId { get; set; } + public int? PausePointHitCount { get; set; } public SimulateKeyboardResponse() { diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardTool.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardTool.cs index b4420e101..8ebbcbf7a 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardTool.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardTool.cs @@ -48,9 +48,9 @@ private static SimulateKeyboardResponse ToResponse(UnityCliLoopKeyboardSimulatio Message = result.Message, Action = result.Action, KeyName = result.KeyName, - InterruptedByDebugBreak = result.InterruptedByDebugBreak, - DebugBreakId = result.DebugBreakId, - DebugBreakHitCount = result.DebugBreakHitCount, + InterruptedByPausePoint = result.InterruptedByPausePoint, + PausePointId = result.PausePointId, + PausePointHitCount = result.PausePointHitCount, }; } } diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs index 39e8c6327..bcab96b80 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs @@ -379,12 +379,12 @@ private static UnityCliLoopKeyboardSimulationResult InterruptedKeyResult( UnityCliLoopKeyboardSimulationResult result = new() { Success = true, - Message = $"Keyboard input stopped because Unity paused during Debug Break inspection. Key '{keyName}' was released from Unity CLI Loop bookkeeping.", + Message = $"Keyboard input stopped because Unity paused during Pause Point inspection. Key '{keyName}' was released from Unity CLI Loop bookkeeping.", Action = action.ToString(), KeyName = keyName, - InterruptedByDebugBreak = true + InterruptedByPausePoint = true }; - AttachDebugBreakHit(result); + AttachPausePointHit(result); return result; } @@ -401,7 +401,7 @@ private static UnityCliLoopKeyboardSimulationResult TimedOutKeyResult( }; } - private static void AttachDebugBreakHit(UnityCliLoopKeyboardSimulationResult result) + private static void AttachPausePointHit(UnityCliLoopKeyboardSimulationResult result) { if (result == null) { @@ -426,8 +426,8 @@ private static void AttachDebugBreakHit(UnityCliLoopKeyboardSimulationResult res return; } - result.DebugBreakId = snapshotId; - result.DebugBreakHitCount = snapshot.HitCount; + result.PausePointId = snapshotId; + result.PausePointHitCount = snapshot.HitCount; } private static async Task FinalizePressOverlay(CancellationToken ct) diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md index 5fbd9d745..b38e239b8 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md @@ -14,7 +14,7 @@ Simulate keyboard input on Unity PlayMode: $ARGUMENTS 1. Ensure Unity is in PlayMode (use `uloop control-play-mode --action Play` if not) 2. Execute the needed `uloop simulate-keyboard` commands 3. Inspect the result with the lightest useful evidence: runtime state, logs, or a screenshot -4. If exact-frame proof would reduce uncertainty, treat Debug Break inspection as an optional follow-up using the section below +4. If exact-frame proof would reduce uncertainty, treat Pause Point inspection as an optional follow-up using the section below 5. Report what happened and which evidence was used ## Tool Reference @@ -44,13 +44,13 @@ Use `Press` for edge-triggered keyboard code such as `Keyboard.current.spaceKey. If a successful `Press` or `KeyDown` leaves `Keyboard.current..isPressed` true but runtime state does not change, do not immediately rewrite the user's runtime code to `isPressed`. First verify that the target component is active during the command, that it polls input in the configured Input System update phase, and that a missed `KeyDown` edge is followed by `KeyUp` before retrying. Use `KeyDown` / `KeyUp` when the scenario intentionally needs a held key. -### Debug Break Inspection (Standard for E2E) +### Pause Point Inspection (Standard for E2E) -- Use `UnityCliLoopDebug.Break("")` with `uloop-wait-for-debug-break` as the standard frame proof when this input drives a state transition you are verifying. Final logs, screenshots, and durable state supplement the paused-frame check but do not replace it. +- Use `UloopPausePoint.Pause("")` with `uloop-wait-for-pause-point` as the standard frame proof when this input drives a state transition you are verifying. Final logs, screenshots, and durable state supplement the paused-frame check but do not replace it. - Put the marker at a natural state transition after the app consumed the key, such as after a command is accepted, a state mutation is committed, an evaluation step resolves, or a dependent component is updated. Do not place it immediately after sending `simulate-keyboard`. -- If the key handler has local variables, intermediate calculations, or branch reasons that `execute-dynamic-code` cannot inspect after the fact, log just those values near `UnityCliLoopDebug.Break("")` and read them with `uloop-get-logs` while Unity is paused. A break hit proves the line was reached, not the frame-local values. +- If the key handler has local variables, intermediate calculations, or branch reasons that `execute-dynamic-code` cannot inspect after the fact, log just those values near `UloopPausePoint.Pause("")` and read them with `uloop-get-logs` while Unity is paused. A pause point hit proves the line was reached, not the frame-local values. - Treat `simulate-keyboard Success=true`, generic action logs, and final durable counters as useful evidence, but not as paused-frame proof. -- If the response has `InterruptedByDebugBreak: true`, Unity is paused for inspection and the tool released its held input bookkeeping. If a `UnityCliLoopDebug.Break` marker caused the pause, `DebugBreakId` and `DebugBreakHitCount` identify it. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. +- If the response has `InterruptedByPausePoint: true`, Unity is paused for inspection and the tool released its held input bookkeeping. If a `UloopPausePoint.Pause` marker caused the pause, `PausePointId` and `PausePointHitCount` identify it. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. - Use distinct marker ids for strict phases, for example `input-read`, `state-updated`, and `result-committed`. ### KeyDown/KeyUp Rules @@ -94,9 +94,9 @@ Returns JSON with: - `Message` (string): Description of what happened or why it failed - `Action` (string): The `--action` value that was applied (`Press`, `KeyDown`, or `KeyUp`) - `KeyName` (string, nullable): The key that was acted on; may be `null` when the action could not resolve a key -- `InterruptedByDebugBreak` (boolean): True when Unity paused during Debug Break inspection and the input bookkeeping was safely released -- `DebugBreakId` (string, nullable): The marker id when a `UnityCliLoopDebug.Break` marker caused the interruption -- `DebugBreakHitCount` (integer, nullable): The marker hit count when a `UnityCliLoopDebug.Break` marker caused the interruption +- `InterruptedByPausePoint` (boolean): True when Unity paused during Pause Point inspection and the input bookkeeping was safely released +- `PausePointId` (string, nullable): The marker id when a `UloopPausePoint.Pause` marker caused the interruption +- `PausePointHitCount` (integer, nullable): The marker hit count when a `UloopPausePoint.Pause` marker caused the interruption ## Prerequisites diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputResponse.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputResponse.cs index 673a755e8..e7c0bc12f 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputResponse.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputResponse.cs @@ -15,9 +15,9 @@ public class SimulateMouseInputResponse : UnityCliLoopToolResponse public string? Button { get; set; } public float? PositionX { get; set; } public float? PositionY { get; set; } - public bool InterruptedByDebugBreak { get; set; } - public string? DebugBreakId { get; set; } - public int? DebugBreakHitCount { get; set; } + public bool InterruptedByPausePoint { get; set; } + public string? PausePointId { get; set; } + public int? PausePointHitCount { get; set; } public SimulateMouseInputResponse() { diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputTool.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputTool.cs index d97a39a1e..16ce5fcb4 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputTool.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputTool.cs @@ -57,9 +57,9 @@ private static SimulateMouseInputResponse ToResponse(UnityCliLoopMouseInputSimul Button = result.Button, PositionX = result.PositionX, PositionY = result.PositionY, - InterruptedByDebugBreak = result.InterruptedByDebugBreak, - DebugBreakId = result.DebugBreakId, - DebugBreakHitCount = result.DebugBreakHitCount, + InterruptedByPausePoint = result.InterruptedByPausePoint, + PausePointId = result.PausePointId, + PausePointHitCount = result.PausePointHitCount, }; } } diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs index 00f31d428..d8e827799 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs @@ -554,11 +554,11 @@ private static UnityCliLoopMouseInputSimulationResult InterruptedActionResult( UnityCliLoopMouseInputSimulationResult result = new() { Success = true, - Message = "Mouse input stopped because Unity paused during Debug Break inspection. Unity CLI Loop released its held input bookkeeping.", + Message = "Mouse input stopped because Unity paused during Pause Point inspection. Unity CLI Loop released its held input bookkeeping.", Action = action.ToString(), - InterruptedByDebugBreak = true + InterruptedByPausePoint = true }; - AttachDebugBreakHit(result); + AttachPausePointHit(result); return result; } @@ -585,7 +585,7 @@ private static UnityCliLoopMouseInputSimulationResult TimedOutActionResult( }; } - private static void AttachDebugBreakHit(UnityCliLoopMouseInputSimulationResult result) + private static void AttachPausePointHit(UnityCliLoopMouseInputSimulationResult result) { if (result == null) { @@ -610,8 +610,8 @@ private static void AttachDebugBreakHit(UnityCliLoopMouseInputSimulationResult r return; } - result.DebugBreakId = snapshotId; - result.DebugBreakHitCount = snapshot.HitCount; + result.PausePointId = snapshotId; + result.PausePointHitCount = snapshot.HitCount; } private static async Task ReleaseButtonIfPossible(Mouse mouse, RuntimeMouseButton button) diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md index 8b1f12c81..1c8dea3b8 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md @@ -15,7 +15,7 @@ Simulate mouse input via Input System in Unity PlayMode. 2. For Click/LongPress: determine the target screen position (use `uloop screenshot` to find coordinates) 3. Execute the needed `uloop simulate-mouse-input` commands 4. Inspect the result with the lightest useful evidence: runtime state, logs, or a screenshot -5. When this input verifies a state transition, use Debug Break inspection from the section below as the standard frame proof +5. When this input verifies a state transition, use Pause Point inspection from the section below as the standard frame proof 6. Report what happened and which evidence was used ## Tool Reference @@ -48,14 +48,14 @@ uloop simulate-mouse-input --action [options] | `SmoothDelta` | Mouse.current.delta (per-frame) | Inject mouse delta smoothly over `--duration` seconds (human-like camera pan) | | `Scroll` | Mouse.current.scroll | Inject scroll wheel input | -### Debug Break Inspection (Standard for E2E) +### Pause Point Inspection (Standard for E2E) -- Use `UnityCliLoopDebug.Break("")` with `uloop-wait-for-debug-break` as the standard frame proof when this input drives a state transition you are verifying. Final logs, screenshots, and durable state supplement the paused-frame check but do not replace it. -- Place the break at a natural state transition after the app consumed the mouse input, such as after a command is accepted, a state mutation is committed, an evaluation step resolves, a tracked value changes, or a dependent component is updated. Do not place it immediately after sending `simulate-mouse-input`. -- If the mouse handler has local variables, intermediate calculations, or branch reasons that `execute-dynamic-code` cannot inspect after the fact, log just those values near `UnityCliLoopDebug.Break("")` and read them with `uloop-get-logs` while Unity is paused. A break hit proves the line was reached, not the frame-local values. -- If the response has `InterruptedByDebugBreak: true`, Unity is paused for inspection and the tool released its held input bookkeeping. `DebugBreakId` and `DebugBreakHitCount` identify the break that paused Unity. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. +- Use `UloopPausePoint.Pause("")` with `uloop-wait-for-pause-point` as the standard frame proof when this input drives a state transition you are verifying. Final logs, screenshots, and durable state supplement the paused-frame check but do not replace it. +- Place the pause point at a natural state transition after the app consumed the mouse input, such as after a command is accepted, a state mutation is committed, an evaluation step resolves, a tracked value changes, or a dependent component is updated. Do not place it immediately after sending `simulate-mouse-input`. +- If the mouse handler has local variables, intermediate calculations, or branch reasons that `execute-dynamic-code` cannot inspect after the fact, log just those values near `UloopPausePoint.Pause("")` and read them with `uloop-get-logs` while Unity is paused. A pause point hit proves the line was reached, not the frame-local values. +- If the response has `InterruptedByPausePoint: true`, Unity is paused for inspection and the tool released its held input bookkeeping. `PausePointId` and `PausePointHitCount` identify the pause point that paused Unity. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. - Use distinct ids for strict phases, for example `input-read`, `state-updated`, and `result-committed`. -- Remove temporary break/log instrumentation before final validation when it was added only for inspection. +- Remove temporary pause-point/log instrumentation before final validation when it was added only for inspection. ### Global Options (optional) @@ -116,8 +116,8 @@ Returns JSON with: - `Button`: Which button was used (nullable string; populated for `Click` / `LongPress`, null otherwise) - `PositionX`: Target X coordinate (nullable float; populated for `Click` / `LongPress`) - `PositionY`: Target Y coordinate (nullable float; populated for `Click` / `LongPress`) -- `InterruptedByDebugBreak`: True when Unity paused during Debug Break inspection and the input bookkeeping was safely released -- `DebugBreakId`: The id from `UnityCliLoopDebug.Break("")` when it caused the interruption -- `DebugBreakHitCount`: The hit count for that `UnityCliLoopDebug.Break("")` +- `InterruptedByPausePoint`: True when Unity paused during Pause Point inspection and the input bookkeeping was safely released +- `PausePointId`: The id from `UloopPausePoint.Pause("")` when it caused the interruption +- `PausePointHitCount`: The hit count for that `UloopPausePoint.Pause("")` -There is no `DeltaX`, `DeltaY`, `ScrollX`, `ScrollY`, `Duration`, or hit-element field in the response — only the issued action, button, target position, and Debug Break interruption state are echoed back. Verify visual outcome with a follow-up screenshot. +There is no `DeltaX`, `DeltaY`, `ScrollX`, `ScrollY`, `Duration`, or hit-element field in the response — only the issued action, button, target position, and Pause Point interruption state are echoed back. Verify visual outcome with a follow-up screenshot. diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/Skill/SKILL.md index 5fd123722..6d64aa1b1 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/Skill/SKILL.md @@ -16,7 +16,7 @@ Simulate mouse interaction on Unity PlayMode UI. 3. Use the `AnnotatedElements` array to find the target element by `Label`, `Name`, or `Path` (A=frontmost, B=next, ...). Use `Interaction` to distinguish click targets from drag/drop/text targets, then use `SimX`/`SimY` directly as `--x`/`--y` coordinates. 4. Execute the needed `uloop simulate-mouse-ui` commands 5. Inspect the result with the lightest useful evidence: runtime state, logs, or a screenshot -6. When this UI input verifies a state transition, use Debug Break inspection from the section below as the standard frame proof +6. When this UI input verifies a state transition, use Pause Point inspection from the section below as the standard frame proof 7. Report what happened and which evidence was used ## Tool Reference @@ -76,14 +76,14 @@ uloop simulate-mouse-ui --action --x --y [options] - `--bypass-raycast` still uses coordinates for pointer event positions, but chooses the clicked, long-pressed, or dragged GameObject by `--target-path` - If `--target-path` or `--drop-target-path` matches multiple active GameObjects, the command fails instead of choosing an arbitrary duplicate -## Debug Break Inspection (Standard for E2E) +## Pause Point Inspection (Standard for E2E) -- Use `UnityCliLoopDebug.Break("")` with `uloop-wait-for-debug-break` as the standard frame proof when this input drives a state transition you are verifying. Final logs, screenshots, and durable state supplement the paused-frame check but do not replace it. -- Place the break at a natural transition after the app consumed the UI event, such as after a command is accepted, a state mutation is committed, a tracked value changes, a UI/domain state syncs, or a success/failure/end condition is entered. -- If the UI handler has local variables, intermediate calculations, or branch reasons that `execute-dynamic-code` cannot inspect after the fact, log just those values near `UnityCliLoopDebug.Break("")` and read them with `uloop-get-logs` while Unity is paused. A break hit proves the line was reached, not the frame-local values. +- Use `UloopPausePoint.Pause("")` with `uloop-wait-for-pause-point` as the standard frame proof when this input drives a state transition you are verifying. Final logs, screenshots, and durable state supplement the paused-frame check but do not replace it. +- Place the pause point at a natural transition after the app consumed the UI event, such as after a command is accepted, a state mutation is committed, a tracked value changes, a UI/domain state syncs, or a success/failure/end condition is entered. +- If the UI handler has local variables, intermediate calculations, or branch reasons that `execute-dynamic-code` cannot inspect after the fact, log just those values near `UloopPausePoint.Pause("")` and read them with `uloop-get-logs` while Unity is paused. A pause point hit proves the line was reached, not the frame-local values. - Treat `simulate-mouse-ui Success=true`, generic action logs, and final durable counters as useful evidence, not paused-frame proof. -- If a `UnityCliLoopDebug.Break` pauses Unity, inspect with `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. -- Remove temporary break/log instrumentation before final validation when it was added only for inspection. +- If a `UloopPausePoint.Pause` pauses Unity, inspect with `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. +- Remove temporary pause-point/log instrumentation before final validation when it was added only for inspection. ## Examples diff --git a/Packages/src/Editor/Infrastructure/Api/InternalBridgeCommandRouter.cs b/Packages/src/Editor/Infrastructure/Api/InternalBridgeCommandRouter.cs index d66a8c633..0c2f0383f 100644 --- a/Packages/src/Editor/Infrastructure/Api/InternalBridgeCommandRouter.cs +++ b/Packages/src/Editor/Infrastructure/Api/InternalBridgeCommandRouter.cs @@ -15,8 +15,8 @@ public static bool IsInternalCommand(string commandName) { return commandName == UnityCliLoopConstants.COMMAND_NAME_GET_VERSION || commandName == UnityCliLoopConstants.COMMAND_NAME_GET_COMPILE_STATUS || - commandName == UnityCliLoopConstants.COMMAND_NAME_GET_DEBUG_BREAK_STATUS || - commandName == UnityCliLoopConstants.COMMAND_NAME_CLEAR_DEBUG_BREAK_STATUS || + commandName == UnityCliLoopConstants.COMMAND_NAME_GET_PAUSE_POINT_STATUS || + commandName == UnityCliLoopConstants.COMMAND_NAME_CLEAR_PAUSE_POINT_STATUS || commandName == UnityCliLoopConstants.COMMAND_NAME_GET_TOOL_DETAILS; } @@ -39,12 +39,12 @@ public static UnityCliLoopToolResponse Execute(string commandName, JToken params return CompileStatusBridgeCommand.Execute(paramsToken); } - if (commandName == UnityCliLoopConstants.COMMAND_NAME_GET_DEBUG_BREAK_STATUS) + if (commandName == UnityCliLoopConstants.COMMAND_NAME_GET_PAUSE_POINT_STATUS) { return PausePointStatusBridgeCommand.Execute(paramsToken); } - if (commandName == UnityCliLoopConstants.COMMAND_NAME_CLEAR_DEBUG_BREAK_STATUS) + if (commandName == UnityCliLoopConstants.COMMAND_NAME_CLEAR_PAUSE_POINT_STATUS) { return PausePointStatusBridgeCommand.Clear(paramsToken); } diff --git a/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs b/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs index 6c1000cda..52d7d16bf 100644 --- a/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs +++ b/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs @@ -7,7 +7,7 @@ namespace io.github.hatayama.UnityCliLoop.Infrastructure { /// - /// Serves CLI-only debug break status and cleanup requests outside the normal tool slot. + /// Serves CLI-only pause point status and cleanup requests outside the normal tool slot. /// internal static class PausePointStatusBridgeCommand { @@ -40,7 +40,7 @@ private static string ReadId(JToken paramsToken) } /// - /// Debug break status payload returned by the internal CLI polling bridge command. + /// Pause point status payload returned by the internal CLI polling bridge command. /// public class PausePointStatusResponse : UnityCliLoopToolResponse { diff --git a/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs b/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs index b5eba119f..13fd12f8c 100644 --- a/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs +++ b/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs @@ -61,8 +61,8 @@ public static UnityEditor.PackageManager.PackageInfo PackageInfo // Command name constants public const string TOOL_NAME_COMPILE = "compile"; public const string TOOL_NAME_CONTROL_PLAY_MODE = "control-play-mode"; - public const string TOOL_NAME_ENABLE_DEBUG_BREAK = "enable-debug-break"; - public const string TOOL_NAME_CLEAR_DEBUG_BREAK = "clear-debug-break"; + public const string TOOL_NAME_ENABLE_PAUSE_POINT = "enable-pause-point"; + public const string TOOL_NAME_CLEAR_PAUSE_POINT = "clear-pause-point"; public const string TOOL_NAME_EXECUTE_DYNAMIC_CODE = "execute-dynamic-code"; public const string TOOL_NAME_GET_HIERARCHY = "get-hierarchy"; public const string TOOL_NAME_GET_LOGS = "get-logs"; @@ -70,10 +70,10 @@ public static UnityEditor.PackageManager.PackageInfo PackageInfo public const string COMMAND_NAME_GET_TOOL_DETAILS = "get-tool-details"; public const string COMMAND_NAME_GET_VERSION = "get-version"; public const string COMMAND_NAME_GET_COMPILE_STATUS = "get-compile-status"; - public const string COMMAND_NAME_WAIT_FOR_DEBUG_BREAK = "wait-for-debug-break"; - public const string COMMAND_NAME_DEBUG_BREAK_STATUS = "debug-break-status"; - public const string COMMAND_NAME_GET_DEBUG_BREAK_STATUS = "get-debug-break-status"; - public const string COMMAND_NAME_CLEAR_DEBUG_BREAK_STATUS = "clear-debug-break-status"; + public const string COMMAND_NAME_WAIT_FOR_PAUSE_POINT = "wait-for-pause-point"; + public const string COMMAND_NAME_PAUSE_POINT_STATUS = "pause-point-status"; + public const string COMMAND_NAME_GET_PAUSE_POINT_STATUS = "get-pause-point-status"; + public const string COMMAND_NAME_CLEAR_PAUSE_POINT_STATUS = "clear-pause-point-status"; // Optional package names public const string PACKAGE_NAME_TEST_FRAMEWORK = "com.unity.test-framework"; diff --git a/Packages/src/Runtime/PausePoints/UnityCliLoopDebug.cs b/Packages/src/Runtime/PausePoints/UloopPausePoint.cs similarity index 57% rename from Packages/src/Runtime/PausePoints/UnityCliLoopDebug.cs rename to Packages/src/Runtime/PausePoints/UloopPausePoint.cs index 5c51e866f..d7572528e 100644 --- a/Packages/src/Runtime/PausePoints/UnityCliLoopDebug.cs +++ b/Packages/src/Runtime/PausePoints/UloopPausePoint.cs @@ -3,15 +3,15 @@ namespace io.github.hatayama.UnityCliLoop.Runtime { /// - /// Provides Editor-only debug helpers for Unity CLI Loop workflows. + /// Provides the Editor-only pause point marker API for Unity CLI Loop workflows. /// - public static class UnityCliLoopDebug + public static class UloopPausePoint { /// - /// Breaks at a named marker when the Editor has enabled the same id. + /// Pauses at a named marker when the Editor has enabled the same id. /// [Conditional("UNITY_EDITOR")] - public static void Break(string id) + public static void Pause(string id) { #if UNITY_EDITOR UloopPausePointRegistry.Hit(id); diff --git a/Packages/src/Runtime/PausePoints/UnityCliLoopDebug.cs.meta b/Packages/src/Runtime/PausePoints/UloopPausePoint.cs.meta similarity index 100% rename from Packages/src/Runtime/PausePoints/UnityCliLoopDebug.cs.meta rename to Packages/src/Runtime/PausePoints/UloopPausePoint.cs.meta diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs index cd650f59c..3aadd89ee 100644 --- a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs +++ b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs @@ -235,7 +235,7 @@ public static UloopPausePointSnapshot NotEnabled(string id, IUloopPausePointPaus 0, pauseController.IsPlaying, pauseController.IsPaused, - "Debug break is not enabled."); + "Pause point is not enabled."); } } @@ -267,7 +267,7 @@ public UloopPausePointEntry(string id, int timeoutSeconds, DateTime enabledAtUtc ExpiresAtUtc = enabledAtUtc.AddSeconds(timeoutSeconds); Status = UloopPausePointStatus.Enabled; IsEnabled = true; - Message = "Debug break enabled."; + Message = "Pause point enabled."; } public string Id { get; } @@ -296,14 +296,14 @@ public void ExpireIfNeeded(DateTime nowUtc) IsEnabled = false; Status = UloopPausePointStatus.Expired; - Message = "Debug break expired before it was hit."; + Message = "Pause point expired before it was hit."; } public void MarkCleared() { IsEnabled = false; Status = UloopPausePointStatus.Cleared; - Message = "Debug break cleared."; + Message = "Pause point cleared."; } public void RecordHit(DateTime nowUtc, bool isPlaying, bool isPaused) @@ -314,7 +314,7 @@ public void RecordHit(DateTime nowUtc, bool isPlaying, bool isPaused) IsPausedAtHit = isPaused; IsEnabled = false; Status = UloopPausePointStatus.Hit; - Message = "Debug break hit; Unity pause was requested."; + Message = "Pause point hit; Unity pause was requested."; } public UloopPausePointSnapshot ToSnapshot(DateTime nowUtc, IUloopPausePointPauseController pauseController) diff --git a/cli/contract.json b/cli/contract.json index d1b2bf8d0..f3667f0b5 100644 --- a/cli/contract.json +++ b/cli/contract.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "cliVersion": "3.0.0-beta.28" + "cliVersion": "3.0.0-beta.29" } diff --git a/cli/internal/cli/command_help.go b/cli/internal/cli/command_help.go index 2bbf0e997..c2d7d8f62 100644 --- a/cli/internal/cli/command_help.go +++ b/cli/internal/cli/command_help.go @@ -211,7 +211,7 @@ func nativeCommandDescription(command string) (string, bool) { func nativeCommandUsesProject(command string) bool { switch command { - case launchCommandName, "list", "sync", "focus-window", skillsCommandName, debugBreakWaitCommandName, debugBreakStatusUserCommandName: + case launchCommandName, "list", "sync", "focus-window", skillsCommandName, pausePointWaitCommandName, pausePointStatusUserCommandName: return true default: return false diff --git a/cli/internal/cli/command_registry.go b/cli/internal/cli/command_registry.go index 927c756ef..453729925 100644 --- a/cli/internal/cli/command_registry.go +++ b/cli/internal/cli/command_registry.go @@ -10,8 +10,8 @@ var nativeCommands = []nativeCommandEntry{ {name: "list", description: "Show Unity tools currently exposed by the Editor"}, {name: "sync", description: "Refresh .uloop/tools.json from the running Editor"}, {name: "focus-window", description: "Bring the Unity Editor window to the foreground"}, - {name: "wait-for-debug-break", description: "Wait until a named UnityCliLoopDebug.Break marker pauses Unity"}, - {name: "debug-break-status", description: "Show the state of a named UnityCliLoopDebug.Break marker"}, + {name: "wait-for-pause-point", description: "Wait until a named UloopPausePoint.Pause marker pauses Unity"}, + {name: "pause-point-status", description: "Show the state of a named UloopPausePoint.Pause marker"}, {name: "skills", description: "List, install, or uninstall agent skills"}, {name: "completion", description: "Print or install shell completion"}, {name: "install", description: "Configure the global uloop launcher binary"}, diff --git a/cli/internal/cli/completion_options.go b/cli/internal/cli/completion_options.go index 1fd89b3a5..0083becb8 100644 --- a/cli/internal/cli/completion_options.go +++ b/cli/internal/cli/completion_options.go @@ -12,13 +12,13 @@ var nativeCommandOptions = map[string][]string{ }, installCommandName: {"--" + installDirFlagName}, updateCommandName: {"--" + updateToVersionFlagName}, - debugBreakWaitCommandName: { - "--" + debugBreakIDFlagName, - "--" + debugBreakTimeoutFlagName, + pausePointWaitCommandName: { + "--" + pausePointIDFlagName, + "--" + pausePointTimeoutFlagName, "--" + projectPathFlagName, }, - debugBreakStatusUserCommandName: { - "--" + debugBreakIDFlagName, + pausePointStatusUserCommandName: { + "--" + pausePointIDFlagName, "--" + projectPathFlagName, }, } diff --git a/cli/internal/cli/debug_break_wait.go b/cli/internal/cli/debug_break_wait.go deleted file mode 100644 index ba68eaf44..000000000 --- a/cli/internal/cli/debug_break_wait.go +++ /dev/null @@ -1,494 +0,0 @@ -package cli - -import ( - "context" - "encoding/json" - "fmt" - "io" - "strconv" - "time" - - "github.com/hatayama/unity-cli-loop/cli/internal/unityipc" -) - -const ( - debugBreakWaitCommandName = "wait-for-debug-break" - debugBreakStatusUserCommandName = "debug-break-status" - debugBreakStatusCommandName = "get-debug-break-status" - debugBreakClearStatusCommandName = "clear-debug-break-status" - debugBreakIDFlagName = "id" - debugBreakTimeoutFlagName = "timeout-seconds" - debugBreakDefaultTimeoutSeconds = 30 - debugBreakStatusProbeTimeout = 5 * time.Second - debugBreakStatusEnabled = "Enabled" - debugBreakStatusHit = "Hit" - debugBreakStatusNotEnabled = "NotEnabled" - debugBreakStatusExpired = "Expired" - debugBreakStatusCleared = "Cleared" - debugBreakFinalStatusProbeTimeout = 250 * time.Millisecond -) - -var ( - debugBreakStatusPoll = 50 * time.Millisecond - queryDebugBreakStatus = queryDebugBreakStatusFromUnity - clearDebugBreakStatus = clearDebugBreakStatusFromUnity -) - -type waitForDebugBreakOptions struct { - id string - timeoutSeconds int - timeout time.Duration -} - -type debugBreakStatusOptions struct { - id string -} - -type debugBreakStatusResponse struct { - Id string `json:"Id"` - Status string `json:"Status"` - IsEnabled bool `json:"IsEnabled"` - IsHit bool `json:"IsHit"` - HitCount int `json:"HitCount"` - TimeoutSeconds int `json:"TimeoutSeconds"` - ElapsedSinceEnabledMilliseconds int64 `json:"ElapsedSinceEnabledMilliseconds"` - IsPlaying bool `json:"IsPlaying"` - IsPaused bool `json:"IsPaused"` - Message string `json:"Message"` -} - -type debugBreakWaitState string - -const ( - debugBreakWaitStateHit debugBreakWaitState = "hit" - debugBreakWaitStateTimeout debugBreakWaitState = "timeout" - debugBreakWaitStateNotEnabled debugBreakWaitState = "not_enabled" - debugBreakWaitStateExpired debugBreakWaitState = "expired" - debugBreakWaitStateCleared debugBreakWaitState = "cleared" -) - -func runWaitForDebugBreakCommand( - ctx context.Context, - connection unityipc.Connection, - args []string, - stdout io.Writer, - stderr io.Writer, -) int { - options, err := parseWaitForDebugBreakOptions(args) - if err != nil { - writeClassifiedError(stderr, err, errorContext{ - projectRoot: connection.ProjectRoot, - command: debugBreakWaitCommandName, - }) - return 1 - } - - return runWaitForDebugBreak(ctx, connection, options, stdout, stderr) -} - -func runDebugBreakStatusCommand( - ctx context.Context, - connection unityipc.Connection, - args []string, - stdout io.Writer, - stderr io.Writer, -) int { - options, err := parseDebugBreakStatusOptions(args) - if err != nil { - writeClassifiedError(stderr, err, errorContext{ - projectRoot: connection.ProjectRoot, - command: debugBreakStatusUserCommandName, - }) - return 1 - } - - response, err := queryDebugBreakStatus(ctx, connection, options.id) - if err != nil { - writeClassifiedError(stderr, err, errorContext{ - projectRoot: connection.ProjectRoot, - command: debugBreakStatusUserCommandName, - }) - return 1 - } - - result, err := json.Marshal(response) - if err != nil { - writeClassifiedError(stderr, err, errorContext{ - projectRoot: connection.ProjectRoot, - command: debugBreakStatusUserCommandName, - }) - return 1 - } - - writeJSON(stdout, result) - return 0 -} - -func runWaitForDebugBreak( - ctx context.Context, - connection unityipc.Connection, - options waitForDebugBreakOptions, - stdout io.Writer, - stderr io.Writer, -) int { - startedAt := time.Now() - spinner := newToolSpinner(stderr, debugBreakWaitCommandName) - response, state, err := waitForDebugBreak(ctx, connection, options) - spinner.Stop() - if err != nil { - writeClassifiedError(stderr, err, errorContext{ - projectRoot: connection.ProjectRoot, - command: debugBreakWaitCommandName, - }) - return 1 - } - - if state == debugBreakWaitStateHit { - result, marshalErr := json.Marshal(response) - if marshalErr != nil { - writeClassifiedError(stderr, marshalErr, errorContext{ - projectRoot: connection.ProjectRoot, - command: debugBreakWaitCommandName, - }) - return 1 - } - writeJSON(stdout, result) - writeDebugTiming(stderr, debugBreakWaitCommandName, time.Since(startedAt), unityipc.UnitySendOutcome{}) - return 0 - } - - if state == debugBreakWaitStateTimeout { - clearDebugBreakAfterWaitTimeout(ctx, connection, options.id) - } - - writeErrorEnvelope(stderr, debugBreakWaitError(connection.ProjectRoot, options, response, state)) - return 1 -} - -func parseWaitForDebugBreakOptions(args []string) (waitForDebugBreakOptions, error) { - options := waitForDebugBreakOptions{ - timeoutSeconds: debugBreakDefaultTimeoutSeconds, - timeout: time.Duration(debugBreakDefaultTimeoutSeconds) * time.Second, - } - - for index := 0; index < len(args); index++ { - arg := args[index] - name, value, consumedNext, err := parseFlagValue(arg, args, index) - if err != nil { - return waitForDebugBreakOptions{}, err - } - - switch name { - case debugBreakIDFlagName: - options.id = value - case debugBreakTimeoutFlagName: - timeoutSeconds, parseErr := parseDebugBreakTimeoutSeconds(value) - if parseErr != nil { - return waitForDebugBreakOptions{}, parseErr - } - options.timeoutSeconds = timeoutSeconds - options.timeout = time.Duration(timeoutSeconds) * time.Second - default: - return waitForDebugBreakOptions{}, &argumentError{ - message: "Unknown option for wait-for-debug-break: --" + name, - option: "--" + name, - command: debugBreakWaitCommandName, - nextActions: []string{"Run `uloop wait-for-debug-break --help` to inspect supported options."}, - } - } - - if consumedNext { - index++ - } - } - - if options.id == "" { - return waitForDebugBreakOptions{}, &argumentError{ - message: "Missing required option: --id", - option: "--" + debugBreakIDFlagName, - expectedType: "value", - command: debugBreakWaitCommandName, - nextActions: []string{"Pass `--id ` matching UnityCliLoopDebug.Break(\"\")."}, - } - } - - return options, nil -} - -func parseDebugBreakStatusOptions(args []string) (debugBreakStatusOptions, error) { - options := debugBreakStatusOptions{} - - for index := 0; index < len(args); index++ { - arg := args[index] - name, value, consumedNext, err := parseFlagValue(arg, args, index) - if err != nil { - return debugBreakStatusOptions{}, err - } - - switch name { - case debugBreakIDFlagName: - options.id = value - default: - return debugBreakStatusOptions{}, &argumentError{ - message: "Unknown option for debug-break-status: --" + name, - option: "--" + name, - command: debugBreakStatusUserCommandName, - nextActions: []string{"Run `uloop debug-break-status --help` to inspect supported options."}, - } - } - - if consumedNext { - index++ - } - } - - if options.id == "" { - return debugBreakStatusOptions{}, &argumentError{ - message: "Missing required option: --id", - option: "--" + debugBreakIDFlagName, - expectedType: "value", - command: debugBreakStatusUserCommandName, - nextActions: []string{"Pass `--id ` matching UnityCliLoopDebug.Break(\"\")."}, - } - } - - return options, nil -} - -func parseDebugBreakTimeoutSeconds(value string) (int, error) { - timeoutSeconds, err := strconv.Atoi(value) - if err != nil || timeoutSeconds <= 0 { - return 0, invalidValueArgumentError("--"+debugBreakTimeoutFlagName, value, "positive integer") - } - return timeoutSeconds, nil -} - -func waitForDebugBreak( - ctx context.Context, - connection unityipc.Connection, - options waitForDebugBreakOptions, -) (debugBreakStatusResponse, debugBreakWaitState, error) { - waitContext, cancel := context.WithTimeout(ctx, options.timeout) - defer cancel() - - lastResponse := debugBreakStatusResponse{Id: options.id} - var lastErr error - hasResponse := false - for { - response, err := queryDebugBreakStatus(waitContext, connection, options.id) - if err == nil { - lastResponse = response - hasResponse = true - state := debugBreakWaitStateForStatus(response.Status) - if state != "" { - return response, state, nil - } - } else { - lastErr = err - } - - select { - case <-waitContext.Done(): - if ctx.Err() != nil { - return lastResponse, "", ctx.Err() - } - finalResponse, finalState, hasFinalResponse, finalErr := queryDebugBreakStatusAtTimeout(ctx, connection, options.id) - if hasFinalResponse { - lastResponse = finalResponse - hasResponse = true - if finalState != "" { - return finalResponse, finalState, nil - } - } else if lastErr == nil { - lastErr = finalErr - } - if hasResponse { - return lastResponse, debugBreakWaitStateTimeout, nil - } - if lastErr != nil { - return lastResponse, "", fmt.Errorf("timed out waiting for debug break status: %w", lastErr) - } - return lastResponse, debugBreakWaitStateTimeout, nil - case <-time.After(debugBreakStatusPoll): - } - } -} - -func queryDebugBreakStatusAtTimeout( - ctx context.Context, - connection unityipc.Connection, - id string, -) (debugBreakStatusResponse, debugBreakWaitState, bool, error) { - finalContext, cancel := context.WithTimeout(ctx, debugBreakFinalStatusProbeTimeout) - defer cancel() - - response, err := queryDebugBreakStatus(finalContext, connection, id) - if err != nil { - return debugBreakStatusResponse{}, "", false, err - } - - return response, debugBreakWaitStateForStatus(response.Status), true, nil -} - -func debugBreakWaitStateForStatus(status string) debugBreakWaitState { - switch status { - case debugBreakStatusHit: - return debugBreakWaitStateHit - case debugBreakStatusNotEnabled: - return debugBreakWaitStateNotEnabled - case debugBreakStatusExpired: - return debugBreakWaitStateExpired - case debugBreakStatusCleared: - return debugBreakWaitStateCleared - case debugBreakStatusEnabled: - return "" - default: - return "" - } -} - -func queryDebugBreakStatusFromUnity( - ctx context.Context, - connection unityipc.Connection, - id string, -) (debugBreakStatusResponse, error) { - probeContext, cancel := context.WithTimeout(ctx, debugBreakStatusProbeTimeout) - defer cancel() - - result, err := unityipc.NewClient(connection, version).Send( - probeContext, - debugBreakStatusCommandName, - map[string]any{"Id": id}, - ) - if err != nil { - return debugBreakStatusResponse{}, err - } - - response := debugBreakStatusResponse{} - if err := json.Unmarshal(result, &response); err != nil { - return debugBreakStatusResponse{}, err - } - return response, nil -} - -func clearDebugBreakStatusFromUnity( - ctx context.Context, - connection unityipc.Connection, - id string, -) (debugBreakStatusResponse, error) { - probeContext, cancel := context.WithTimeout(ctx, debugBreakStatusProbeTimeout) - defer cancel() - - result, err := unityipc.NewClient(connection, version).Send( - probeContext, - debugBreakClearStatusCommandName, - map[string]any{"Id": id}, - ) - if err != nil { - return debugBreakStatusResponse{}, err - } - - response := debugBreakStatusResponse{} - if err := json.Unmarshal(result, &response); err != nil { - return debugBreakStatusResponse{}, err - } - return response, nil -} - -func clearDebugBreakAfterWaitTimeout(ctx context.Context, connection unityipc.Connection, id string) { - clearContext, cancel := context.WithTimeout(ctx, debugBreakStatusProbeTimeout) - defer cancel() - _, _ = clearDebugBreakStatus(clearContext, connection, id) -} - -func debugBreakWaitError( - projectRoot string, - options waitForDebugBreakOptions, - response debugBreakStatusResponse, - state debugBreakWaitState, -) cliError { - switch state { - case debugBreakWaitStateNotEnabled: - return debugBreakStateError( - errorCodeDebugBreakNotEnabled, - "Debug break is not enabled.", - projectRoot, - options, - response, - false) - case debugBreakWaitStateExpired: - return debugBreakStateError( - errorCodeDebugBreakExpired, - "Debug break expired before it was hit.", - projectRoot, - options, - response, - true) - case debugBreakWaitStateCleared: - return debugBreakStateError( - errorCodeDebugBreakCleared, - "Debug break was cleared before it was hit.", - projectRoot, - options, - response, - true) - default: - return debugBreakStateError( - errorCodeDebugBreakWaitTimeout, - fmt.Sprintf("Debug break was not hit within %ds.", options.timeoutSeconds), - projectRoot, - options, - response, - true) - } -} - -func debugBreakStateError( - errorCode string, - message string, - projectRoot string, - options waitForDebugBreakOptions, - response debugBreakStatusResponse, - retryable bool, -) cliError { - return cliError{ - ErrorCode: errorCode, - Phase: errorPhaseResponseWaiting, - Message: message, - Retryable: retryable, - SafeToRetry: retryable, - ProjectRoot: projectRoot, - Command: debugBreakWaitCommandName, - NextActions: []string{ - "Run `uloop enable-debug-break --id ` before waiting.", - "Confirm the code path calls `UnityCliLoopDebug.Break(\"\")` with the same id.", - "Check `details.status`, `details.isPlaying`, `details.isPaused`, `details.elapsedSinceEnabledMilliseconds`, and `details.remainingMilliseconds` to distinguish a missed code path from an already-paused Editor.", - "If the marker is inside a custom asmdef, add a reference to `UnityCLILoop.PausePoints.Runtime`.", - }, - Details: map[string]any{ - "id": options.id, - "status": response.Status, - "hitCount": response.HitCount, - "timeoutSeconds": options.timeoutSeconds, - "elapsedSinceEnabledMilliseconds": response.ElapsedSinceEnabledMilliseconds, - "isPlaying": response.IsPlaying, - "isPaused": response.IsPaused, - "remainingMilliseconds": debugBreakRemainingMilliseconds(options, response), - "markerMessage": response.Message, - }, - } -} - -func debugBreakRemainingMilliseconds(options waitForDebugBreakOptions, response debugBreakStatusResponse) int64 { - timeoutSeconds := response.TimeoutSeconds - if timeoutSeconds <= 0 { - return 0 - } - - totalMilliseconds := int64(timeoutSeconds) * int64(time.Second/time.Millisecond) - remainingMilliseconds := totalMilliseconds - response.ElapsedSinceEnabledMilliseconds - if remainingMilliseconds <= 0 { - return 0 - } - return remainingMilliseconds -} diff --git a/cli/internal/cli/error_envelope.go b/cli/internal/cli/error_envelope.go index a2e4313b5..a41c8e464 100644 --- a/cli/internal/cli/error_envelope.go +++ b/cli/internal/cli/error_envelope.go @@ -24,10 +24,10 @@ const ( errorCodeToolDisabled = "TOOL_DISABLED" errorCodeCompileWaitTimeout = "COMPILE_WAIT_TIMEOUT" errorCodeControlPlayModeWaitTimeout = "CONTROL_PLAY_MODE_WAIT_TIMEOUT" - errorCodeDebugBreakNotEnabled = "DEBUG_BREAK_NOT_ENABLED" - errorCodeDebugBreakWaitTimeout = "DEBUG_BREAK_WAIT_TIMEOUT" - errorCodeDebugBreakExpired = "DEBUG_BREAK_EXPIRED" - errorCodeDebugBreakCleared = "DEBUG_BREAK_CLEARED" + errorCodePausePointNotEnabled = "PAUSE_POINT_NOT_ENABLED" + errorCodePausePointWaitTimeout = "PAUSE_POINT_WAIT_TIMEOUT" + errorCodePausePointExpired = "PAUSE_POINT_EXPIRED" + errorCodePausePointCleared = "PAUSE_POINT_CLEARED" errorCodeInternalError = "INTERNAL_ERROR" errorPhaseArgumentParsing = "argument_parsing" diff --git a/cli/internal/cli/error_envelope_test.go b/cli/internal/cli/error_envelope_test.go index 2136ea5b3..42592bf91 100644 --- a/cli/internal/cli/error_envelope_test.go +++ b/cli/internal/cli/error_envelope_test.go @@ -453,7 +453,7 @@ func TestClassifyConnectionAttemptUsesContextProjectRootFallback(t *testing.T) { func TestAvailableCommandNamesIncludesBuiltIns(t *testing.T) { names := availableCommandNames(toolsCache{}) - expectedBuiltIns := []string{"launch", "list", "sync", "focus-window", "wait-for-debug-break", "debug-break-status", "skills", "completion", "install", "update"} + expectedBuiltIns := []string{"launch", "list", "sync", "focus-window", "wait-for-pause-point", "pause-point-status", "skills", "completion", "install", "update"} for index, expected := range expectedBuiltIns { if names[index] != expected { t.Fatalf("built-in command mismatch: %#v", names) diff --git a/cli/internal/cli/native_tool_settings.go b/cli/internal/cli/native_tool_settings.go index 0ccf7f7b2..3cf97d71f 100644 --- a/cli/internal/cli/native_tool_settings.go +++ b/cli/internal/cli/native_tool_settings.go @@ -2,7 +2,7 @@ package cli func isSettingsManagedNativeToolCommand(command string) bool { switch command { - case debugBreakWaitCommandName, debugBreakStatusUserCommandName: + case pausePointWaitCommandName, pausePointStatusUserCommandName: return true default: return false diff --git a/cli/internal/cli/pause_point_wait.go b/cli/internal/cli/pause_point_wait.go new file mode 100644 index 000000000..1fca2a722 --- /dev/null +++ b/cli/internal/cli/pause_point_wait.go @@ -0,0 +1,494 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strconv" + "time" + + "github.com/hatayama/unity-cli-loop/cli/internal/unityipc" +) + +const ( + pausePointWaitCommandName = "wait-for-pause-point" + pausePointStatusUserCommandName = "pause-point-status" + pausePointStatusCommandName = "get-pause-point-status" + pausePointClearStatusCommandName = "clear-pause-point-status" + pausePointIDFlagName = "id" + pausePointTimeoutFlagName = "timeout-seconds" + pausePointDefaultTimeoutSeconds = 30 + pausePointStatusProbeTimeout = 5 * time.Second + pausePointStatusEnabled = "Enabled" + pausePointStatusHit = "Hit" + pausePointStatusNotEnabled = "NotEnabled" + pausePointStatusExpired = "Expired" + pausePointStatusCleared = "Cleared" + pausePointFinalStatusProbeTimeout = 250 * time.Millisecond +) + +var ( + pausePointStatusPoll = 50 * time.Millisecond + queryPausePointStatus = queryPausePointStatusFromUnity + clearPausePointStatus = clearPausePointStatusFromUnity +) + +type waitForPausePointOptions struct { + id string + timeoutSeconds int + timeout time.Duration +} + +type pausePointStatusOptions struct { + id string +} + +type pausePointStatusResponse struct { + Id string `json:"Id"` + Status string `json:"Status"` + IsEnabled bool `json:"IsEnabled"` + IsHit bool `json:"IsHit"` + HitCount int `json:"HitCount"` + TimeoutSeconds int `json:"TimeoutSeconds"` + ElapsedSinceEnabledMilliseconds int64 `json:"ElapsedSinceEnabledMilliseconds"` + IsPlaying bool `json:"IsPlaying"` + IsPaused bool `json:"IsPaused"` + Message string `json:"Message"` +} + +type pausePointWaitState string + +const ( + pausePointWaitStateHit pausePointWaitState = "hit" + pausePointWaitStateTimeout pausePointWaitState = "timeout" + pausePointWaitStateNotEnabled pausePointWaitState = "not_enabled" + pausePointWaitStateExpired pausePointWaitState = "expired" + pausePointWaitStateCleared pausePointWaitState = "cleared" +) + +func runWaitForPausePointCommand( + ctx context.Context, + connection unityipc.Connection, + args []string, + stdout io.Writer, + stderr io.Writer, +) int { + options, err := parseWaitForPausePointOptions(args) + if err != nil { + writeClassifiedError(stderr, err, errorContext{ + projectRoot: connection.ProjectRoot, + command: pausePointWaitCommandName, + }) + return 1 + } + + return runWaitForPausePoint(ctx, connection, options, stdout, stderr) +} + +func runPausePointStatusCommand( + ctx context.Context, + connection unityipc.Connection, + args []string, + stdout io.Writer, + stderr io.Writer, +) int { + options, err := parsePausePointStatusOptions(args) + if err != nil { + writeClassifiedError(stderr, err, errorContext{ + projectRoot: connection.ProjectRoot, + command: pausePointStatusUserCommandName, + }) + return 1 + } + + response, err := queryPausePointStatus(ctx, connection, options.id) + if err != nil { + writeClassifiedError(stderr, err, errorContext{ + projectRoot: connection.ProjectRoot, + command: pausePointStatusUserCommandName, + }) + return 1 + } + + result, err := json.Marshal(response) + if err != nil { + writeClassifiedError(stderr, err, errorContext{ + projectRoot: connection.ProjectRoot, + command: pausePointStatusUserCommandName, + }) + return 1 + } + + writeJSON(stdout, result) + return 0 +} + +func runWaitForPausePoint( + ctx context.Context, + connection unityipc.Connection, + options waitForPausePointOptions, + stdout io.Writer, + stderr io.Writer, +) int { + startedAt := time.Now() + spinner := newToolSpinner(stderr, pausePointWaitCommandName) + response, state, err := waitForPausePoint(ctx, connection, options) + spinner.Stop() + if err != nil { + writeClassifiedError(stderr, err, errorContext{ + projectRoot: connection.ProjectRoot, + command: pausePointWaitCommandName, + }) + return 1 + } + + if state == pausePointWaitStateHit { + result, marshalErr := json.Marshal(response) + if marshalErr != nil { + writeClassifiedError(stderr, marshalErr, errorContext{ + projectRoot: connection.ProjectRoot, + command: pausePointWaitCommandName, + }) + return 1 + } + writeJSON(stdout, result) + writeDebugTiming(stderr, pausePointWaitCommandName, time.Since(startedAt), unityipc.UnitySendOutcome{}) + return 0 + } + + if state == pausePointWaitStateTimeout { + clearPausePointAfterWaitTimeout(ctx, connection, options.id) + } + + writeErrorEnvelope(stderr, pausePointWaitError(connection.ProjectRoot, options, response, state)) + return 1 +} + +func parseWaitForPausePointOptions(args []string) (waitForPausePointOptions, error) { + options := waitForPausePointOptions{ + timeoutSeconds: pausePointDefaultTimeoutSeconds, + timeout: time.Duration(pausePointDefaultTimeoutSeconds) * time.Second, + } + + for index := 0; index < len(args); index++ { + arg := args[index] + name, value, consumedNext, err := parseFlagValue(arg, args, index) + if err != nil { + return waitForPausePointOptions{}, err + } + + switch name { + case pausePointIDFlagName: + options.id = value + case pausePointTimeoutFlagName: + timeoutSeconds, parseErr := parsePausePointTimeoutSeconds(value) + if parseErr != nil { + return waitForPausePointOptions{}, parseErr + } + options.timeoutSeconds = timeoutSeconds + options.timeout = time.Duration(timeoutSeconds) * time.Second + default: + return waitForPausePointOptions{}, &argumentError{ + message: "Unknown option for wait-for-pause-point: --" + name, + option: "--" + name, + command: pausePointWaitCommandName, + nextActions: []string{"Run `uloop wait-for-pause-point --help` to inspect supported options."}, + } + } + + if consumedNext { + index++ + } + } + + if options.id == "" { + return waitForPausePointOptions{}, &argumentError{ + message: "Missing required option: --id", + option: "--" + pausePointIDFlagName, + expectedType: "value", + command: pausePointWaitCommandName, + nextActions: []string{"Pass `--id ` matching UloopPausePoint.Pause(\"\")."}, + } + } + + return options, nil +} + +func parsePausePointStatusOptions(args []string) (pausePointStatusOptions, error) { + options := pausePointStatusOptions{} + + for index := 0; index < len(args); index++ { + arg := args[index] + name, value, consumedNext, err := parseFlagValue(arg, args, index) + if err != nil { + return pausePointStatusOptions{}, err + } + + switch name { + case pausePointIDFlagName: + options.id = value + default: + return pausePointStatusOptions{}, &argumentError{ + message: "Unknown option for pause-point-status: --" + name, + option: "--" + name, + command: pausePointStatusUserCommandName, + nextActions: []string{"Run `uloop pause-point-status --help` to inspect supported options."}, + } + } + + if consumedNext { + index++ + } + } + + if options.id == "" { + return pausePointStatusOptions{}, &argumentError{ + message: "Missing required option: --id", + option: "--" + pausePointIDFlagName, + expectedType: "value", + command: pausePointStatusUserCommandName, + nextActions: []string{"Pass `--id ` matching UloopPausePoint.Pause(\"\")."}, + } + } + + return options, nil +} + +func parsePausePointTimeoutSeconds(value string) (int, error) { + timeoutSeconds, err := strconv.Atoi(value) + if err != nil || timeoutSeconds <= 0 { + return 0, invalidValueArgumentError("--"+pausePointTimeoutFlagName, value, "positive integer") + } + return timeoutSeconds, nil +} + +func waitForPausePoint( + ctx context.Context, + connection unityipc.Connection, + options waitForPausePointOptions, +) (pausePointStatusResponse, pausePointWaitState, error) { + waitContext, cancel := context.WithTimeout(ctx, options.timeout) + defer cancel() + + lastResponse := pausePointStatusResponse{Id: options.id} + var lastErr error + hasResponse := false + for { + response, err := queryPausePointStatus(waitContext, connection, options.id) + if err == nil { + lastResponse = response + hasResponse = true + state := pausePointWaitStateForStatus(response.Status) + if state != "" { + return response, state, nil + } + } else { + lastErr = err + } + + select { + case <-waitContext.Done(): + if ctx.Err() != nil { + return lastResponse, "", ctx.Err() + } + finalResponse, finalState, hasFinalResponse, finalErr := queryPausePointStatusAtTimeout(ctx, connection, options.id) + if hasFinalResponse { + lastResponse = finalResponse + hasResponse = true + if finalState != "" { + return finalResponse, finalState, nil + } + } else if lastErr == nil { + lastErr = finalErr + } + if hasResponse { + return lastResponse, pausePointWaitStateTimeout, nil + } + if lastErr != nil { + return lastResponse, "", fmt.Errorf("timed out waiting for pause point status: %w", lastErr) + } + return lastResponse, pausePointWaitStateTimeout, nil + case <-time.After(pausePointStatusPoll): + } + } +} + +func queryPausePointStatusAtTimeout( + ctx context.Context, + connection unityipc.Connection, + id string, +) (pausePointStatusResponse, pausePointWaitState, bool, error) { + finalContext, cancel := context.WithTimeout(ctx, pausePointFinalStatusProbeTimeout) + defer cancel() + + response, err := queryPausePointStatus(finalContext, connection, id) + if err != nil { + return pausePointStatusResponse{}, "", false, err + } + + return response, pausePointWaitStateForStatus(response.Status), true, nil +} + +func pausePointWaitStateForStatus(status string) pausePointWaitState { + switch status { + case pausePointStatusHit: + return pausePointWaitStateHit + case pausePointStatusNotEnabled: + return pausePointWaitStateNotEnabled + case pausePointStatusExpired: + return pausePointWaitStateExpired + case pausePointStatusCleared: + return pausePointWaitStateCleared + case pausePointStatusEnabled: + return "" + default: + return "" + } +} + +func queryPausePointStatusFromUnity( + ctx context.Context, + connection unityipc.Connection, + id string, +) (pausePointStatusResponse, error) { + probeContext, cancel := context.WithTimeout(ctx, pausePointStatusProbeTimeout) + defer cancel() + + result, err := unityipc.NewClient(connection, version).Send( + probeContext, + pausePointStatusCommandName, + map[string]any{"Id": id}, + ) + if err != nil { + return pausePointStatusResponse{}, err + } + + response := pausePointStatusResponse{} + if err := json.Unmarshal(result, &response); err != nil { + return pausePointStatusResponse{}, err + } + return response, nil +} + +func clearPausePointStatusFromUnity( + ctx context.Context, + connection unityipc.Connection, + id string, +) (pausePointStatusResponse, error) { + probeContext, cancel := context.WithTimeout(ctx, pausePointStatusProbeTimeout) + defer cancel() + + result, err := unityipc.NewClient(connection, version).Send( + probeContext, + pausePointClearStatusCommandName, + map[string]any{"Id": id}, + ) + if err != nil { + return pausePointStatusResponse{}, err + } + + response := pausePointStatusResponse{} + if err := json.Unmarshal(result, &response); err != nil { + return pausePointStatusResponse{}, err + } + return response, nil +} + +func clearPausePointAfterWaitTimeout(ctx context.Context, connection unityipc.Connection, id string) { + clearContext, cancel := context.WithTimeout(ctx, pausePointStatusProbeTimeout) + defer cancel() + _, _ = clearPausePointStatus(clearContext, connection, id) +} + +func pausePointWaitError( + projectRoot string, + options waitForPausePointOptions, + response pausePointStatusResponse, + state pausePointWaitState, +) cliError { + switch state { + case pausePointWaitStateNotEnabled: + return pausePointStateError( + errorCodePausePointNotEnabled, + "Pause point is not enabled.", + projectRoot, + options, + response, + false) + case pausePointWaitStateExpired: + return pausePointStateError( + errorCodePausePointExpired, + "Pause point expired before it was hit.", + projectRoot, + options, + response, + true) + case pausePointWaitStateCleared: + return pausePointStateError( + errorCodePausePointCleared, + "Pause point was cleared before it was hit.", + projectRoot, + options, + response, + true) + default: + return pausePointStateError( + errorCodePausePointWaitTimeout, + fmt.Sprintf("Pause point was not hit within %ds.", options.timeoutSeconds), + projectRoot, + options, + response, + true) + } +} + +func pausePointStateError( + errorCode string, + message string, + projectRoot string, + options waitForPausePointOptions, + response pausePointStatusResponse, + retryable bool, +) cliError { + return cliError{ + ErrorCode: errorCode, + Phase: errorPhaseResponseWaiting, + Message: message, + Retryable: retryable, + SafeToRetry: retryable, + ProjectRoot: projectRoot, + Command: pausePointWaitCommandName, + NextActions: []string{ + "Run `uloop enable-pause-point --id ` before waiting.", + "Confirm the code path calls `UloopPausePoint.Pause(\"\")` with the same id.", + "Check `details.status`, `details.isPlaying`, `details.isPaused`, `details.elapsedSinceEnabledMilliseconds`, and `details.remainingMilliseconds` to distinguish a missed code path from an already-paused Editor.", + "If the marker is inside a custom asmdef, add a reference to `UnityCLILoop.PausePoints.Runtime`.", + }, + Details: map[string]any{ + "id": options.id, + "status": response.Status, + "hitCount": response.HitCount, + "timeoutSeconds": options.timeoutSeconds, + "elapsedSinceEnabledMilliseconds": response.ElapsedSinceEnabledMilliseconds, + "isPlaying": response.IsPlaying, + "isPaused": response.IsPaused, + "remainingMilliseconds": pausePointRemainingMilliseconds(options, response), + "markerMessage": response.Message, + }, + } +} + +func pausePointRemainingMilliseconds(options waitForPausePointOptions, response pausePointStatusResponse) int64 { + timeoutSeconds := response.TimeoutSeconds + if timeoutSeconds <= 0 { + return 0 + } + + totalMilliseconds := int64(timeoutSeconds) * int64(time.Second/time.Millisecond) + remainingMilliseconds := totalMilliseconds - response.ElapsedSinceEnabledMilliseconds + if remainingMilliseconds <= 0 { + return 0 + } + return remainingMilliseconds +} diff --git a/cli/internal/cli/debug_break_wait_test.go b/cli/internal/cli/pause_point_wait_test.go similarity index 55% rename from cli/internal/cli/debug_break_wait_test.go rename to cli/internal/cli/pause_point_wait_test.go index a690ec5d5..8704b61ba 100644 --- a/cli/internal/cli/debug_break_wait_test.go +++ b/cli/internal/cli/pause_point_wait_test.go @@ -12,26 +12,26 @@ import ( "github.com/hatayama/unity-cli-loop/cli/internal/unityipc" ) -// Verifies wait-for-debug-break polls until Unity reports the marker hit. -func TestWaitForDebugBreakReturnsHitAfterEnabledStatus(t *testing.T) { - originalQuery := queryDebugBreakStatus - originalPoll := debugBreakStatusPoll - debugBreakStatusPoll = time.Millisecond +// Verifies wait-for-pause-point polls until Unity reports the marker hit. +func TestWaitForPausePointReturnsHitAfterEnabledStatus(t *testing.T) { + originalQuery := queryPausePointStatus + originalPoll := pausePointStatusPoll + pausePointStatusPoll = time.Millisecond defer func() { - queryDebugBreakStatus = originalQuery - debugBreakStatusPoll = originalPoll + queryPausePointStatus = originalQuery + pausePointStatusPoll = originalPoll }() - responses := []debugBreakStatusResponse{ - {Id: "jump", Status: debugBreakStatusEnabled, IsEnabled: true}, - {Id: "jump", Status: debugBreakStatusHit, IsHit: true, IsPaused: true, HitCount: 1}, + responses := []pausePointStatusResponse{ + {Id: "jump", Status: pausePointStatusEnabled, IsEnabled: true}, + {Id: "jump", Status: pausePointStatusHit, IsHit: true, IsPaused: true, HitCount: 1}, } requestCount := 0 - queryDebugBreakStatus = func( + queryPausePointStatus = func( ctx context.Context, connection unityipc.Connection, id string, - ) (debugBreakStatusResponse, error) { + ) (pausePointStatusResponse, error) { if id != "jump" { t.Fatalf("id mismatch: %s", id) } @@ -40,18 +40,18 @@ func TestWaitForDebugBreakReturnsHitAfterEnabledStatus(t *testing.T) { return response, nil } - response, state, err := waitForDebugBreak(context.Background(), unityipc.Connection{}, waitForDebugBreakOptions{ + response, state, err := waitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ id: "jump", timeoutSeconds: 1, timeout: time.Second, }) if err != nil { - t.Fatalf("waitForDebugBreak failed: %v", err) + t.Fatalf("waitForPausePoint failed: %v", err) } - if state != debugBreakWaitStateHit { + if state != pausePointWaitStateHit { t.Fatalf("state mismatch: %s", state) } - if response.Status != debugBreakStatusHit || response.HitCount != 1 { + if response.Status != pausePointStatusHit || response.HitCount != 1 { t.Fatalf("response mismatch: %#v", response) } if requestCount != 2 { @@ -59,48 +59,48 @@ func TestWaitForDebugBreakReturnsHitAfterEnabledStatus(t *testing.T) { } } -// Verifies wait-for-debug-break clears the enabled marker after its own timeout. -func TestRunWaitForDebugBreakClearsEnabledMarkerAfterTimeout(t *testing.T) { - originalQuery := queryDebugBreakStatus - originalClear := clearDebugBreakStatus - originalPoll := debugBreakStatusPoll - debugBreakStatusPoll = time.Millisecond +// Verifies wait-for-pause-point clears the enabled marker after its own timeout. +func TestRunWaitForPausePointClearsEnabledMarkerAfterTimeout(t *testing.T) { + originalQuery := queryPausePointStatus + originalClear := clearPausePointStatus + originalPoll := pausePointStatusPoll + pausePointStatusPoll = time.Millisecond defer func() { - queryDebugBreakStatus = originalQuery - clearDebugBreakStatus = originalClear - debugBreakStatusPoll = originalPoll + queryPausePointStatus = originalQuery + clearPausePointStatus = originalClear + pausePointStatusPoll = originalPoll }() - queryDebugBreakStatus = func( + queryPausePointStatus = func( ctx context.Context, connection unityipc.Connection, id string, - ) (debugBreakStatusResponse, error) { - return debugBreakStatusResponse{ + ) (pausePointStatusResponse, error) { + return pausePointStatusResponse{ Id: id, - Status: debugBreakStatusEnabled, + Status: pausePointStatusEnabled, IsEnabled: true, TimeoutSeconds: 1, ElapsedSinceEnabledMilliseconds: 100, IsPlaying: true, IsPaused: false, - Message: "Debug break enabled.", + Message: "Pause point enabled.", }, nil } clearedID := "" - clearDebugBreakStatus = func( + clearPausePointStatus = func( ctx context.Context, connection unityipc.Connection, id string, - ) (debugBreakStatusResponse, error) { + ) (pausePointStatusResponse, error) { clearedID = id - return debugBreakStatusResponse{Id: id, Status: debugBreakStatusCleared}, nil + return pausePointStatusResponse{Id: id, Status: pausePointStatusCleared}, nil } var stdout bytes.Buffer var stderr bytes.Buffer - code := runWaitForDebugBreak(context.Background(), unityipc.Connection{}, waitForDebugBreakOptions{ + code := runWaitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ id: "jump", timeoutSeconds: 1, timeout: 5 * time.Millisecond, @@ -112,17 +112,17 @@ func TestRunWaitForDebugBreakClearsEnabledMarkerAfterTimeout(t *testing.T) { if clearedID != "jump" { t.Fatalf("cleared id mismatch: %s", clearedID) } - if !strings.Contains(stderr.String(), errorCodeDebugBreakWaitTimeout) { + if !strings.Contains(stderr.String(), errorCodePausePointWaitTimeout) { t.Fatalf("timeout error missing from stderr: %s", stderr.String()) } - envelope := parseDebugBreakErrorEnvelope(t, stderr.Bytes()) + envelope := parsePausePointErrorEnvelope(t, stderr.Bytes()) if envelope.Error.Details["isPlaying"] != true { t.Fatalf("isPlaying detail mismatch: %#v", envelope.Error.Details) } if envelope.Error.Details["isPaused"] != false { t.Fatalf("isPaused detail mismatch: %#v", envelope.Error.Details) } - if envelope.Error.Details["markerMessage"] != "Debug break enabled." { + if envelope.Error.Details["markerMessage"] != "Pause point enabled." { t.Fatalf("markerMessage detail mismatch: %#v", envelope.Error.Details) } if envelope.Error.Details["elapsedSinceEnabledMilliseconds"] != float64(100) { @@ -133,32 +133,32 @@ func TestRunWaitForDebugBreakClearsEnabledMarkerAfterTimeout(t *testing.T) { } } -// Verifies wait-for-debug-break does one final status probe before treating timeout as missed. -func TestRunWaitForDebugBreakReturnsFinalHitAtTimeoutBoundary(t *testing.T) { - originalQuery := queryDebugBreakStatus - originalClear := clearDebugBreakStatus +// Verifies wait-for-pause-point does one final status probe before treating timeout as missed. +func TestRunWaitForPausePointReturnsFinalHitAtTimeoutBoundary(t *testing.T) { + originalQuery := queryPausePointStatus + originalClear := clearPausePointStatus defer func() { - queryDebugBreakStatus = originalQuery - clearDebugBreakStatus = originalClear + queryPausePointStatus = originalQuery + clearPausePointStatus = originalClear }() requestCount := 0 - queryDebugBreakStatus = func( + queryPausePointStatus = func( ctx context.Context, connection unityipc.Connection, id string, - ) (debugBreakStatusResponse, error) { + ) (pausePointStatusResponse, error) { requestCount++ if requestCount == 1 { - return debugBreakStatusResponse{ + return pausePointStatusResponse{ Id: id, - Status: debugBreakStatusEnabled, + Status: pausePointStatusEnabled, IsEnabled: true, }, nil } - return debugBreakStatusResponse{ + return pausePointStatusResponse{ Id: id, - Status: debugBreakStatusHit, + Status: pausePointStatusHit, IsHit: true, IsPaused: true, HitCount: 1, @@ -166,18 +166,18 @@ func TestRunWaitForDebugBreakReturnsFinalHitAtTimeoutBoundary(t *testing.T) { } clearedID := "" - clearDebugBreakStatus = func( + clearPausePointStatus = func( ctx context.Context, connection unityipc.Connection, id string, - ) (debugBreakStatusResponse, error) { + ) (pausePointStatusResponse, error) { clearedID = id - return debugBreakStatusResponse{Id: id, Status: debugBreakStatusCleared}, nil + return pausePointStatusResponse{Id: id, Status: pausePointStatusCleared}, nil } var stdout bytes.Buffer var stderr bytes.Buffer - code := runWaitForDebugBreak(context.Background(), unityipc.Connection{}, waitForDebugBreakOptions{ + code := runWaitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ id: "jump", timeoutSeconds: 1, timeout: 5 * time.Millisecond, @@ -189,11 +189,11 @@ func TestRunWaitForDebugBreakReturnsFinalHitAtTimeoutBoundary(t *testing.T) { if clearedID != "" { t.Fatalf("marker should not be cleared after final hit: %s", clearedID) } - var response debugBreakStatusResponse + 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 response.Status != debugBreakStatusHit || response.HitCount != 1 { + if response.Status != pausePointStatusHit || response.HitCount != 1 { t.Fatalf("response mismatch: %#v", response) } if requestCount != 2 { @@ -201,61 +201,61 @@ func TestRunWaitForDebugBreakReturnsFinalHitAtTimeoutBoundary(t *testing.T) { } } -// Verifies wait-for-debug-break rejects calls before the marker is enabled. -func TestWaitForDebugBreakReturnsNotEnabledStateImmediately(t *testing.T) { - originalQuery := queryDebugBreakStatus +// Verifies wait-for-pause-point rejects calls before the marker is enabled. +func TestWaitForPausePointReturnsNotEnabledStateImmediately(t *testing.T) { + originalQuery := queryPausePointStatus defer func() { - queryDebugBreakStatus = originalQuery + queryPausePointStatus = originalQuery }() - queryDebugBreakStatus = func( + queryPausePointStatus = func( ctx context.Context, connection unityipc.Connection, id string, - ) (debugBreakStatusResponse, error) { - return debugBreakStatusResponse{Id: id, Status: debugBreakStatusNotEnabled, IsPlaying: true}, nil + ) (pausePointStatusResponse, error) { + return pausePointStatusResponse{Id: id, Status: pausePointStatusNotEnabled, IsPlaying: true}, nil } - response, state, err := waitForDebugBreak(context.Background(), unityipc.Connection{}, waitForDebugBreakOptions{ + response, state, err := waitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ id: "jump", timeoutSeconds: 1, timeout: time.Second, }) if err != nil { - t.Fatalf("waitForDebugBreak failed: %v", err) + t.Fatalf("waitForPausePoint failed: %v", err) } - if state != debugBreakWaitStateNotEnabled { + if state != pausePointWaitStateNotEnabled { t.Fatalf("state mismatch: %s", state) } - if response.Status != debugBreakStatusNotEnabled { + if response.Status != pausePointStatusNotEnabled { t.Fatalf("response mismatch: %#v", response) } } // Verifies not-enabled failures use the user-facing enabled terminology. -func TestRunWaitForDebugBreakReportsNotEnabledError(t *testing.T) { - originalQuery := queryDebugBreakStatus +func TestRunWaitForPausePointReportsNotEnabledError(t *testing.T) { + originalQuery := queryPausePointStatus defer func() { - queryDebugBreakStatus = originalQuery + queryPausePointStatus = originalQuery }() - queryDebugBreakStatus = func( + queryPausePointStatus = func( ctx context.Context, connection unityipc.Connection, id string, - ) (debugBreakStatusResponse, error) { - return debugBreakStatusResponse{ + ) (pausePointStatusResponse, error) { + return pausePointStatusResponse{ Id: id, - Status: debugBreakStatusNotEnabled, + Status: pausePointStatusNotEnabled, IsPlaying: true, IsPaused: false, - Message: "Debug break is not enabled.", + Message: "Pause point is not enabled.", }, nil } var stdout bytes.Buffer var stderr bytes.Buffer - code := runWaitForDebugBreak(context.Background(), unityipc.Connection{}, waitForDebugBreakOptions{ + code := runWaitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ id: "jump", timeoutSeconds: 1, timeout: time.Second, @@ -264,11 +264,11 @@ func TestRunWaitForDebugBreakReportsNotEnabledError(t *testing.T) { if code != 1 { t.Fatalf("expected failure, got %d with stdout %s", code, stdout.String()) } - envelope := parseDebugBreakErrorEnvelope(t, stderr.Bytes()) - if envelope.Error.ErrorCode != errorCodeDebugBreakNotEnabled { + envelope := parsePausePointErrorEnvelope(t, stderr.Bytes()) + if envelope.Error.ErrorCode != errorCodePausePointNotEnabled { t.Fatalf("error code mismatch: %#v", envelope.Error) } - if envelope.Error.Details["status"] != debugBreakStatusNotEnabled { + if envelope.Error.Details["status"] != pausePointStatusNotEnabled { t.Fatalf("status detail mismatch: %#v", envelope.Error.Details) } if envelope.Error.Details["isPlaying"] != true || envelope.Error.Details["isPaused"] != false { @@ -277,22 +277,22 @@ func TestRunWaitForDebugBreakReportsNotEnabledError(t *testing.T) { } // Verifies expired markers report no remaining enabled lifetime. -func TestDebugBreakExpiredErrorReportsNoRemainingTime(t *testing.T) { - response := debugBreakStatusResponse{ +func TestPausePointExpiredErrorReportsNoRemainingTime(t *testing.T) { + response := pausePointStatusResponse{ Id: "jump", - Status: debugBreakStatusExpired, + Status: pausePointStatusExpired, TimeoutSeconds: 1, ElapsedSinceEnabledMilliseconds: 1200, IsPlaying: true, - Message: "Debug break expired before it was hit.", + Message: "Pause point expired before it was hit.", } - cliErr := debugBreakWaitError("/tmp/MyProject", waitForDebugBreakOptions{ + cliErr := pausePointWaitError("/tmp/MyProject", waitForPausePointOptions{ id: "jump", timeoutSeconds: 1, - }, response, debugBreakWaitStateExpired) + }, response, pausePointWaitStateExpired) - if cliErr.ErrorCode != errorCodeDebugBreakExpired { + if cliErr.ErrorCode != errorCodePausePointExpired { t.Fatalf("error code mismatch: %#v", cliErr) } if cliErr.Details["remainingMilliseconds"] != int64(0) { @@ -300,35 +300,35 @@ func TestDebugBreakExpiredErrorReportsNoRemainingTime(t *testing.T) { } } -// Verifies disabled native debug-break commands are rejected before Unity dispatch. -func TestRunProjectLocalWaitForDebugBreakRespectsToolSettings(t *testing.T) { +// Verifies disabled native pause-point commands are rejected before Unity dispatch. +func TestRunProjectLocalWaitForPausePointRespectsToolSettings(t *testing.T) { projectRoot := createLaunchTestProject(t) - writeToolSettings(t, projectRoot, `{"disabledTools":["wait-for-debug-break"]}`) + writeToolSettings(t, projectRoot, `{"disabledTools":["wait-for-pause-point"]}`) t.Chdir(filepath.Dir(projectRoot)) var stdout bytes.Buffer var stderr bytes.Buffer code := RunProjectLocal( context.Background(), - []string{"--project-path", projectRoot, debugBreakWaitCommandName, "--id", "jump"}, + []string{"--project-path", projectRoot, pausePointWaitCommandName, "--id", "jump"}, &stdout, &stderr) if code != 1 { t.Fatalf("expected disabled command failure, got %d with stdout %s", code, stdout.String()) } - envelope := parseDebugBreakErrorEnvelope(t, stderr.Bytes()) + envelope := parsePausePointErrorEnvelope(t, stderr.Bytes()) if envelope.Error.ErrorCode != errorCodeToolDisabled { t.Fatalf("error code mismatch: %#v", envelope.Error) } - if envelope.Error.Command != debugBreakWaitCommandName { + if envelope.Error.Command != pausePointWaitCommandName { t.Fatalf("command mismatch: %#v", envelope.Error) } } -// Verifies wait-for-debug-break requires a marker id. -func TestParseWaitForDebugBreakOptionsRequiresID(t *testing.T) { - _, err := parseWaitForDebugBreakOptions([]string{"--timeout-seconds", "1"}) +// Verifies wait-for-pause-point requires a marker id. +func TestParseWaitForPausePointOptionsRequiresID(t *testing.T) { + _, err := parseWaitForPausePointOptions([]string{"--timeout-seconds", "1"}) if err == nil { t.Fatal("expected missing id error") @@ -338,24 +338,24 @@ func TestParseWaitForDebugBreakOptionsRequiresID(t *testing.T) { } } -// Verifies debug-break-status reports the current marker state without waiting for a hit. -func TestRunDebugBreakStatusReturnsCurrentStatus(t *testing.T) { - originalQuery := queryDebugBreakStatus +// Verifies pause-point-status reports the current marker state without waiting for a hit. +func TestRunPausePointStatusReturnsCurrentStatus(t *testing.T) { + originalQuery := queryPausePointStatus defer func() { - queryDebugBreakStatus = originalQuery + queryPausePointStatus = originalQuery }() - queryDebugBreakStatus = func( + queryPausePointStatus = func( ctx context.Context, connection unityipc.Connection, id string, - ) (debugBreakStatusResponse, error) { - return debugBreakStatusResponse{Id: id, Status: debugBreakStatusEnabled, IsEnabled: true}, nil + ) (pausePointStatusResponse, error) { + return pausePointStatusResponse{Id: id, Status: pausePointStatusEnabled, IsEnabled: true}, nil } var stdout bytes.Buffer var stderr bytes.Buffer - code := runDebugBreakStatusCommand( + code := runPausePointStatusCommand( context.Background(), unityipc.Connection{ProjectRoot: "/tmp/MyProject"}, []string{"--id", "jump"}, @@ -365,18 +365,18 @@ func TestRunDebugBreakStatusReturnsCurrentStatus(t *testing.T) { if code != 0 { t.Fatalf("expected success, got %d with stderr %s", code, stderr.String()) } - var response debugBreakStatusResponse + 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 response.Status != debugBreakStatusEnabled { + if response.Status != pausePointStatusEnabled { t.Fatalf("status mismatch: %#v", response) } } -// Verifies debug-break-status requires a marker id. -func TestParseDebugBreakStatusOptionsRequiresID(t *testing.T) { - _, err := parseDebugBreakStatusOptions([]string{}) +// Verifies pause-point-status requires a marker id. +func TestParsePausePointStatusOptionsRequiresID(t *testing.T) { + _, err := parsePausePointStatusOptions([]string{}) if err == nil { t.Fatal("expected missing id error") @@ -386,7 +386,7 @@ func TestParseDebugBreakStatusOptionsRequiresID(t *testing.T) { } } -func parseDebugBreakErrorEnvelope(t *testing.T, payload []byte) cliErrorEnvelope { +func parsePausePointErrorEnvelope(t *testing.T, payload []byte) cliErrorEnvelope { t.Helper() var envelope cliErrorEnvelope diff --git a/cli/internal/cli/run.go b/cli/internal/cli/run.go index 5e02fe12d..92bafa454 100644 --- a/cli/internal/cli/run.go +++ b/cli/internal/cli/run.go @@ -91,10 +91,10 @@ func RunProjectLocal(ctx context.Context, args []string, stdout io.Writer, stder return runSync(ctx, connection, stdout, stderr) case "focus-window": return runFocusWindow(ctx, connection.ProjectRoot, stdout, stderr) - case debugBreakWaitCommandName: - return runWaitForDebugBreakCommand(ctx, connection, commandArgs, stdout, stderr) - case debugBreakStatusUserCommandName: - return runDebugBreakStatusCommand(ctx, connection, commandArgs, stdout, stderr) + case pausePointWaitCommandName: + return runWaitForPausePointCommand(ctx, connection, commandArgs, stdout, stderr) + case pausePointStatusUserCommandName: + return runPausePointStatusCommand(ctx, connection, commandArgs, stdout, stderr) default: tool, cache, ok, err := findToolForCommand(connection.ProjectRoot, command) if err != nil { diff --git a/cli/internal/tools/default-tools.json b/cli/internal/tools/default-tools.json index 90a4c143c..3f5ef6d19 100644 --- a/cli/internal/tools/default-tools.json +++ b/cli/internal/tools/default-tools.json @@ -329,14 +329,14 @@ } }, { - "name": "enable-debug-break", - "description": "Enable a named UnityCliLoopDebug.Break marker so Unity pauses when that code path is reached", + "name": "enable-pause-point", + "description": "Enable a named UloopPausePoint.Pause marker so Unity pauses when that code path is reached", "inputSchema": { "type": "object", "properties": { "Id": { "type": "string", - "description": "Named debug break id passed to UnityCliLoopDebug.Break" + "description": "Named pause point id passed to UloopPausePoint.Pause" }, "TimeoutSeconds": { "type": "integer", @@ -347,18 +347,18 @@ } }, { - "name": "clear-debug-break", - "description": "Clear one or all named UnityCliLoopDebug.Break markers", + "name": "clear-pause-point", + "description": "Clear one or all named UloopPausePoint.Pause markers", "inputSchema": { "type": "object", "properties": { "Id": { "type": "string", - "description": "Named debug break id to clear" + "description": "Named pause point id to clear" }, "All": { "type": "boolean", - "description": "Clear every active debug break marker" + "description": "Clear every active pause point marker" } } } From a5528265831a3237445c905f2e414bc71f759e6d Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 11 Jun 2026 01:14:59 +0900 Subject: [PATCH 02/31] Make clear results explain why nothing was cleared Agents reported that clearing an already-hit one-shot marker returned ClearedCount: 0 with the message "cleared.", which read as a contradiction. Clear now resolves expiry first and reports a state-specific message for already-hit, expired, and already-cleared markers, and bulk clear says "No active pause points to clear." when nothing was armed. --- Assets/Tests/Editor/PausePointTests.cs | 52 +++++++++++++++++++ .../PausePoint/PausePointTools.cs | 4 +- .../PausePoints/UloopPausePointRegistry.cs | 15 ++++-- 3 files changed, 67 insertions(+), 4 deletions(-) diff --git a/Assets/Tests/Editor/PausePointTests.cs b/Assets/Tests/Editor/PausePointTests.cs index 515a1d3f7..90c83c23d 100644 --- a/Assets/Tests/Editor/PausePointTests.cs +++ b/Assets/Tests/Editor/PausePointTests.cs @@ -125,6 +125,58 @@ public void Clear_WhenPausePointIsEnabled_DisablesWithoutPause() Assert.That(_pauseController.PauseCount, Is.EqualTo(0)); } + [Test] + public void Clear_WhenPausePointWasHit_ReportsAlreadyHitMessage() + { + // Verifies clearing an already-hit one-shot marker explains why nothing was armed anymore. + UloopPausePointRegistry.Enable("jump", 30); + UloopPausePoint.Pause("jump"); + + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.Clear("jump"); + + Assert.That(snapshot.Message, Is.EqualTo("Pause point was already hit (auto-disarmed); nothing to clear.")); + } + + [Test] + public void Clear_WhenPausePointExpired_ReportsAlreadyExpiredMessage() + { + // Verifies clearing an expired marker explains it was never hit instead of claiming a clear. + UloopPausePointRegistry.Enable("jump", 1); + _nowUtc = _nowUtc.AddSeconds(2); + + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.Clear("jump"); + + Assert.That(snapshot.Message, Is.EqualTo("Pause point had already expired before being hit; nothing to clear.")); + } + + [Test] + public void Clear_WhenPausePointAlreadyCleared_ReportsAlreadyClearedMessage() + { + // Verifies a repeated clear reports the marker was already cleared. + UloopPausePointRegistry.Enable("jump", 30); + UloopPausePointRegistry.Clear("jump"); + + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.Clear("jump"); + + Assert.That(snapshot.Message, Is.EqualTo("Pause point was already cleared.")); + } + + [Test] + public async Task ClearAll_WhenNothingActive_ReportsNoActiveMessage() + { + // Verifies bulk clear with no armed markers does not claim that markers were cleared. + ClearPausePointTool tool = new(); + JObject parameters = new() + { + ["all"] = true + }; + + PausePointResponse response = (PausePointResponse)await tool.ExecuteAsync(parameters, CancellationToken.None); + + Assert.That(response.ClearedCount, Is.EqualTo(0)); + Assert.That(response.Message, Is.EqualTo("No active pause points to clear.")); + } + [Test] public void Enable_WhenSamePausePointWasHit_ClearsLatestHitSnapshot() { diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs index 39c6ca918..4409f1c3e 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs @@ -79,7 +79,9 @@ internal static PausePointResponse FromClearAll(UloopPausePointClearAllResult re { Status = UloopPausePointStatus.Cleared, ClearedCount = result.ClearedCount, - Message = "Pause points cleared." + Message = result.ClearedCount == 0 + ? "No active pause points to clear." + : "Pause points cleared." }; } } diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs index 3aadd89ee..ad16a7f8f 100644 --- a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs +++ b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs @@ -41,7 +41,16 @@ public static UloopPausePointSnapshot Clear(string id) } UloopPausePointEntry entry = Entries[id]; - entry.MarkCleared(); + // Resolve expiry first so a clear after the timeout reports "expired", not a normal clear. + entry.ExpireIfNeeded(now); + string message = entry.Status switch + { + UloopPausePointStatus.Hit => "Pause point was already hit (auto-disarmed); nothing to clear.", + UloopPausePointStatus.Expired => "Pause point had already expired before being hit; nothing to clear.", + UloopPausePointStatus.Cleared => "Pause point was already cleared.", + _ => "Pause point cleared." + }; + entry.MarkCleared(message); ClearLatestHitSnapshotIfMatches(id); return entry.ToSnapshot(now, _pauseController); } @@ -299,11 +308,11 @@ public void ExpireIfNeeded(DateTime nowUtc) Message = "Pause point expired before it was hit."; } - public void MarkCleared() + public void MarkCleared(string message = "Pause point cleared.") { IsEnabled = false; Status = UloopPausePointStatus.Cleared; - Message = "Pause point cleared."; + Message = message; } public void RecordHit(DateTime nowUtc, bool isPlaying, bool isPaused) From 70d6c9ac8a915567fc8d896ce3954720f20e20b4 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 11 Jun 2026 01:17:23 +0900 Subject: [PATCH 03/31] Add a diagnosis hint to pause point wait timeouts Agents found timeout root-causing hard: the same error covers a missed code path, a stopped PlayMode, and an already-paused Editor. The timeout error now carries details.hint with a deterministic diagnosis for those three states so the next action is obvious without extra status probing. --- cli/internal/cli/pause_point_errors.go | 118 ++++++++++++++++++++++ cli/internal/cli/pause_point_wait.go | 92 ----------------- cli/internal/cli/pause_point_wait_test.go | 59 +++++++++++ 3 files changed, 177 insertions(+), 92 deletions(-) create mode 100644 cli/internal/cli/pause_point_errors.go diff --git a/cli/internal/cli/pause_point_errors.go b/cli/internal/cli/pause_point_errors.go new file mode 100644 index 000000000..1beb91f04 --- /dev/null +++ b/cli/internal/cli/pause_point_errors.go @@ -0,0 +1,118 @@ +package cli + +import ( + "fmt" + "time" +) + +func pausePointWaitError( + projectRoot string, + options waitForPausePointOptions, + response pausePointStatusResponse, + state pausePointWaitState, +) cliError { + switch state { + case pausePointWaitStateNotEnabled: + return pausePointStateError( + errorCodePausePointNotEnabled, + "Pause point is not enabled.", + projectRoot, + options, + response, + false) + case pausePointWaitStateExpired: + return pausePointStateError( + errorCodePausePointExpired, + "Pause point expired before it was hit.", + projectRoot, + options, + response, + true) + case pausePointWaitStateCleared: + return pausePointStateError( + errorCodePausePointCleared, + "Pause point was cleared before it was hit.", + projectRoot, + options, + response, + true) + default: + timeoutError := pausePointStateError( + errorCodePausePointWaitTimeout, + fmt.Sprintf("Pause point was not hit within %ds.", options.timeoutSeconds), + projectRoot, + options, + response, + true) + hint := pausePointTimeoutHint(response) + if hint != "" { + timeoutError.Details["hint"] = hint + } + return timeoutError + } +} + +// pausePointTimeoutHint maps the final probed status to a deterministic diagnosis, +// because timeouts are where agents struggle to tell a missed code path from Editor state. +func pausePointTimeoutHint(response pausePointStatusResponse) string { + if !response.IsPlaying { + return "PlayMode is not running. Start PlayMode (or trigger the marker code path in Edit Mode), then wait again." + } + if response.IsPaused { + return "Unity is already paused, so gameplay cannot reach the marker. Resume PlayMode before waiting again." + } + if response.HitCount == 0 && response.Status == pausePointStatusEnabled { + return "Marker was enabled but never hit. Confirm the id matches UloopPausePoint.Pause(\"\") and that the code path was executed." + } + return "" +} + +func pausePointStateError( + errorCode string, + message string, + projectRoot string, + options waitForPausePointOptions, + response pausePointStatusResponse, + retryable bool, +) cliError { + return cliError{ + ErrorCode: errorCode, + Phase: errorPhaseResponseWaiting, + Message: message, + Retryable: retryable, + SafeToRetry: retryable, + ProjectRoot: projectRoot, + Command: pausePointWaitCommandName, + NextActions: []string{ + "Run `uloop enable-pause-point --id ` before waiting.", + "Confirm the code path calls `UloopPausePoint.Pause(\"\")` with the same id.", + "Check `details.status`, `details.isPlaying`, `details.isPaused`, `details.elapsedSinceEnabledMilliseconds`, and `details.remainingMilliseconds` to distinguish a missed code path from an already-paused Editor.", + "If the marker is inside a custom asmdef, add a reference to `UnityCLILoop.PausePoints.Runtime`.", + }, + Details: map[string]any{ + "id": options.id, + "status": response.Status, + "hitCount": response.HitCount, + "timeoutSeconds": options.timeoutSeconds, + "elapsedSinceEnabledMilliseconds": response.ElapsedSinceEnabledMilliseconds, + "isPlaying": response.IsPlaying, + "isPaused": response.IsPaused, + "remainingMilliseconds": pausePointRemainingMilliseconds(options, response), + "markerMessage": response.Message, + }, + } +} + +func pausePointRemainingMilliseconds(options waitForPausePointOptions, response pausePointStatusResponse) int64 { + timeoutSeconds := response.TimeoutSeconds + if timeoutSeconds <= 0 { + return 0 + } + + totalMilliseconds := int64(timeoutSeconds) * int64(time.Second/time.Millisecond) + remainingMilliseconds := totalMilliseconds - response.ElapsedSinceEnabledMilliseconds + if remainingMilliseconds <= 0 { + return 0 + } + return remainingMilliseconds +} diff --git a/cli/internal/cli/pause_point_wait.go b/cli/internal/cli/pause_point_wait.go index 1fca2a722..b759643c9 100644 --- a/cli/internal/cli/pause_point_wait.go +++ b/cli/internal/cli/pause_point_wait.go @@ -400,95 +400,3 @@ func clearPausePointAfterWaitTimeout(ctx context.Context, connection unityipc.Co defer cancel() _, _ = clearPausePointStatus(clearContext, connection, id) } - -func pausePointWaitError( - projectRoot string, - options waitForPausePointOptions, - response pausePointStatusResponse, - state pausePointWaitState, -) cliError { - switch state { - case pausePointWaitStateNotEnabled: - return pausePointStateError( - errorCodePausePointNotEnabled, - "Pause point is not enabled.", - projectRoot, - options, - response, - false) - case pausePointWaitStateExpired: - return pausePointStateError( - errorCodePausePointExpired, - "Pause point expired before it was hit.", - projectRoot, - options, - response, - true) - case pausePointWaitStateCleared: - return pausePointStateError( - errorCodePausePointCleared, - "Pause point was cleared before it was hit.", - projectRoot, - options, - response, - true) - default: - return pausePointStateError( - errorCodePausePointWaitTimeout, - fmt.Sprintf("Pause point was not hit within %ds.", options.timeoutSeconds), - projectRoot, - options, - response, - true) - } -} - -func pausePointStateError( - errorCode string, - message string, - projectRoot string, - options waitForPausePointOptions, - response pausePointStatusResponse, - retryable bool, -) cliError { - return cliError{ - ErrorCode: errorCode, - Phase: errorPhaseResponseWaiting, - Message: message, - Retryable: retryable, - SafeToRetry: retryable, - ProjectRoot: projectRoot, - Command: pausePointWaitCommandName, - NextActions: []string{ - "Run `uloop enable-pause-point --id ` before waiting.", - "Confirm the code path calls `UloopPausePoint.Pause(\"\")` with the same id.", - "Check `details.status`, `details.isPlaying`, `details.isPaused`, `details.elapsedSinceEnabledMilliseconds`, and `details.remainingMilliseconds` to distinguish a missed code path from an already-paused Editor.", - "If the marker is inside a custom asmdef, add a reference to `UnityCLILoop.PausePoints.Runtime`.", - }, - Details: map[string]any{ - "id": options.id, - "status": response.Status, - "hitCount": response.HitCount, - "timeoutSeconds": options.timeoutSeconds, - "elapsedSinceEnabledMilliseconds": response.ElapsedSinceEnabledMilliseconds, - "isPlaying": response.IsPlaying, - "isPaused": response.IsPaused, - "remainingMilliseconds": pausePointRemainingMilliseconds(options, response), - "markerMessage": response.Message, - }, - } -} - -func pausePointRemainingMilliseconds(options waitForPausePointOptions, response pausePointStatusResponse) int64 { - timeoutSeconds := response.TimeoutSeconds - if timeoutSeconds <= 0 { - return 0 - } - - totalMilliseconds := int64(timeoutSeconds) * int64(time.Second/time.Millisecond) - remainingMilliseconds := totalMilliseconds - response.ElapsedSinceEnabledMilliseconds - if remainingMilliseconds <= 0 { - return 0 - } - return remainingMilliseconds -} diff --git a/cli/internal/cli/pause_point_wait_test.go b/cli/internal/cli/pause_point_wait_test.go index 8704b61ba..f95ad4eae 100644 --- a/cli/internal/cli/pause_point_wait_test.go +++ b/cli/internal/cli/pause_point_wait_test.go @@ -276,6 +276,65 @@ func TestRunWaitForPausePointReportsNotEnabledError(t *testing.T) { } } +// Verifies timeout errors include a deterministic diagnosis hint for common stuck states. +func TestPausePointTimeoutErrorIncludesDiagnosisHint(t *testing.T) { + cases := []struct { + name string + response pausePointStatusResponse + wantHint string + }{ + { + name: "play mode not running", + response: pausePointStatusResponse{Id: "jump", Status: pausePointStatusEnabled, IsPlaying: false}, + wantHint: "PlayMode is not running. Start PlayMode (or trigger the marker code path in Edit Mode), then wait again.", + }, + { + name: "editor already paused", + response: pausePointStatusResponse{Id: "jump", Status: pausePointStatusEnabled, IsPlaying: true, IsPaused: true}, + wantHint: "Unity is already paused, so gameplay cannot reach the marker. Resume PlayMode before waiting again.", + }, + { + name: "marker never hit", + response: pausePointStatusResponse{Id: "jump", Status: pausePointStatusEnabled, IsPlaying: true, HitCount: 0}, + wantHint: "Marker was enabled but never hit. Confirm the id matches UloopPausePoint.Pause(\"\") and that the code path was executed.", + }, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + cliErr := pausePointWaitError("/tmp/MyProject", waitForPausePointOptions{ + id: "jump", + timeoutSeconds: 1, + }, testCase.response, pausePointWaitStateTimeout) + + if cliErr.Details["hint"] != testCase.wantHint { + t.Fatalf("hint mismatch: %#v", cliErr.Details) + } + }) + } +} + +// Verifies hints stay scoped to timeouts and to diagnosable response states. +func TestPausePointHintIsOmittedOutsideDiagnosableTimeouts(t *testing.T) { + hitResponse := pausePointStatusResponse{Id: "jump", Status: pausePointStatusHit, IsPlaying: true, HitCount: 1} + timeoutErr := pausePointWaitError("/tmp/MyProject", waitForPausePointOptions{ + id: "jump", + timeoutSeconds: 1, + }, hitResponse, pausePointWaitStateTimeout) + if _, exists := timeoutErr.Details["hint"]; exists { + t.Fatalf("hint should be omitted when no diagnosis applies: %#v", timeoutErr.Details) + } + + notPlayingResponse := pausePointStatusResponse{Id: "jump", Status: pausePointStatusExpired, IsPlaying: false} + expiredErr := pausePointWaitError("/tmp/MyProject", waitForPausePointOptions{ + id: "jump", + timeoutSeconds: 1, + }, notPlayingResponse, pausePointWaitStateExpired) + if _, exists := expiredErr.Details["hint"]; exists { + t.Fatalf("hint should be omitted for non-timeout states: %#v", expiredErr.Details) + } +} + // Verifies expired markers report no remaining enabled lifetime. func TestPausePointExpiredErrorReportsNoRemainingTime(t *testing.T) { response := pausePointStatusResponse{ From 60035e5240398265fde6bc02b46925e6b3a04ac5 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 11 Jun 2026 01:23:43 +0900 Subject: [PATCH 04/31] Embed marker-matching logs in wait-for-pause-point output After a hit, agents always followed up with get-logs --search-text to read the values logged next to the marker, and that extra call could race a busy paused Editor. With --include-matching-logs (and an optional --matching-logs-max-count, default 10), the wait response now embeds the matching log entries directly; timeouts attach them best-effort under details.matchingLogs. Log fetch failures are ignored so a hit never turns into an error. --- cli/internal/cli/completion_options.go | 2 + cli/internal/cli/pause_point_logs.go | 65 +++++++ cli/internal/cli/pause_point_wait.go | 46 ++++- cli/internal/cli/pause_point_wait_test.go | 226 ++++++++++++++++++++++ 4 files changed, 332 insertions(+), 7 deletions(-) create mode 100644 cli/internal/cli/pause_point_logs.go diff --git a/cli/internal/cli/completion_options.go b/cli/internal/cli/completion_options.go index 0083becb8..b3447a29f 100644 --- a/cli/internal/cli/completion_options.go +++ b/cli/internal/cli/completion_options.go @@ -15,6 +15,8 @@ var nativeCommandOptions = map[string][]string{ pausePointWaitCommandName: { "--" + pausePointIDFlagName, "--" + pausePointTimeoutFlagName, + "--" + pausePointIncludeLogsFlagName, + "--" + pausePointLogsMaxCountFlagName, "--" + projectPathFlagName, }, pausePointStatusUserCommandName: { diff --git a/cli/internal/cli/pause_point_logs.go b/cli/internal/cli/pause_point_logs.go new file mode 100644 index 000000000..553b6d80a --- /dev/null +++ b/cli/internal/cli/pause_point_logs.go @@ -0,0 +1,65 @@ +package cli + +import ( + "context" + "encoding/json" + + "github.com/hatayama/unity-cli-loop/cli/internal/unityipc" +) + +const ( + pausePointIncludeLogsFlagName = "include-matching-logs" + pausePointLogsMaxCountFlagName = "matching-logs-max-count" + pausePointDefaultLogsMaxCount = 10 + pausePointGetLogsCommandName = "get-logs" +) + +// Injectable so tests can simulate Unity log responses without an Editor. +var fetchMatchingLogs = fetchMatchingLogsFromUnity + +type pausePointMatchingLog struct { + Type string `json:"Type"` + Message string `json:"Message"` +} + +// pausePointWaitResult extends the hit response with marker-matching logs so +// agents do not need a separate get-logs call while Unity is paused. +type pausePointWaitResult struct { + pausePointStatusResponse + MatchingLogs []pausePointMatchingLog `json:"MatchingLogs"` +} + +type pausePointGetLogsResponse struct { + Logs []pausePointMatchingLog `json:"Logs"` +} + +func fetchMatchingLogsFromUnity( + ctx context.Context, + connection unityipc.Connection, + searchText string, + maxCount int, +) ([]pausePointMatchingLog, error) { + probeContext, cancel := context.WithTimeout(ctx, pausePointStatusProbeTimeout) + defer cancel() + + result, err := unityipc.NewClient(connection, version).Send( + probeContext, + pausePointGetLogsCommandName, + map[string]any{ + "SearchText": searchText, + "MaxCount": maxCount, + }, + ) + if err != nil { + return nil, err + } + + response := pausePointGetLogsResponse{} + if err := json.Unmarshal(result, &response); err != nil { + return nil, err + } + if response.Logs == nil { + return []pausePointMatchingLog{}, nil + } + return response.Logs, nil +} diff --git a/cli/internal/cli/pause_point_wait.go b/cli/internal/cli/pause_point_wait.go index b759643c9..2e366c6e9 100644 --- a/cli/internal/cli/pause_point_wait.go +++ b/cli/internal/cli/pause_point_wait.go @@ -35,9 +35,11 @@ var ( ) type waitForPausePointOptions struct { - id string - timeoutSeconds int - timeout time.Duration + id string + timeoutSeconds int + timeout time.Duration + includeMatchingLogs bool + matchingLogsMaxCount int } type pausePointStatusOptions struct { @@ -144,7 +146,15 @@ func runWaitForPausePoint( } if state == pausePointWaitStateHit { - result, marshalErr := json.Marshal(response) + var payload any = response + if options.includeMatchingLogs { + // Best-effort: a hit must stay a success even if Unity is busy while paused. + logs, logsErr := fetchMatchingLogs(ctx, connection, options.id, options.matchingLogsMaxCount) + if logsErr == nil { + payload = pausePointWaitResult{pausePointStatusResponse: response, MatchingLogs: logs} + } + } + result, marshalErr := json.Marshal(payload) if marshalErr != nil { writeClassifiedError(stderr, marshalErr, errorContext{ projectRoot: connection.ProjectRoot, @@ -161,18 +171,33 @@ func runWaitForPausePoint( clearPausePointAfterWaitTimeout(ctx, connection, options.id) } - writeErrorEnvelope(stderr, pausePointWaitError(connection.ProjectRoot, options, response, state)) + waitErr := pausePointWaitError(connection.ProjectRoot, options, response, state) + if options.includeMatchingLogs && state == pausePointWaitStateTimeout { + // Best-effort: the timeout diagnosis must not depend on a second Unity round trip succeeding. + logs, logsErr := fetchMatchingLogs(ctx, connection, options.id, options.matchingLogsMaxCount) + if logsErr == nil { + waitErr.Details["matchingLogs"] = logs + } + } + writeErrorEnvelope(stderr, waitErr) return 1 } func parseWaitForPausePointOptions(args []string) (waitForPausePointOptions, error) { options := waitForPausePointOptions{ - timeoutSeconds: pausePointDefaultTimeoutSeconds, - timeout: time.Duration(pausePointDefaultTimeoutSeconds) * time.Second, + timeoutSeconds: pausePointDefaultTimeoutSeconds, + timeout: time.Duration(pausePointDefaultTimeoutSeconds) * time.Second, + matchingLogsMaxCount: pausePointDefaultLogsMaxCount, } for index := 0; index < len(args); index++ { arg := args[index] + // The include flag is a bare boolean; parseFlagValue would demand a value for it. + if arg == "--"+pausePointIncludeLogsFlagName { + options.includeMatchingLogs = true + continue + } + name, value, consumedNext, err := parseFlagValue(arg, args, index) if err != nil { return waitForPausePointOptions{}, err @@ -188,6 +213,13 @@ func parseWaitForPausePointOptions(args []string) (waitForPausePointOptions, err } options.timeoutSeconds = timeoutSeconds options.timeout = time.Duration(timeoutSeconds) * time.Second + case pausePointLogsMaxCountFlagName: + maxCount, parseErr := strconv.Atoi(value) + if parseErr != nil || maxCount <= 0 { + return waitForPausePointOptions{}, invalidValueArgumentError( + "--"+pausePointLogsMaxCountFlagName, value, "positive integer") + } + options.matchingLogsMaxCount = maxCount default: return waitForPausePointOptions{}, &argumentError{ message: "Unknown option for wait-for-pause-point: --" + name, diff --git a/cli/internal/cli/pause_point_wait_test.go b/cli/internal/cli/pause_point_wait_test.go index f95ad4eae..05a396e60 100644 --- a/cli/internal/cli/pause_point_wait_test.go +++ b/cli/internal/cli/pause_point_wait_test.go @@ -276,6 +276,232 @@ func TestRunWaitForPausePointReportsNotEnabledError(t *testing.T) { } } +// Verifies matching-log flags parse with safe defaults and reject non-positive counts. +func TestParseWaitForPausePointOptionsParsesMatchingLogFlags(t *testing.T) { + defaults, err := parseWaitForPausePointOptions([]string{"--id", "jump"}) + if err != nil { + t.Fatalf("default parse failed: %v", err) + } + if defaults.includeMatchingLogs || defaults.matchingLogsMaxCount != pausePointDefaultLogsMaxCount { + t.Fatalf("default matching-log options mismatch: %#v", defaults) + } + + options, err := parseWaitForPausePointOptions([]string{ + "--id", "jump", "--include-matching-logs", "--matching-logs-max-count", "5", + }) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + if !options.includeMatchingLogs || options.matchingLogsMaxCount != 5 { + t.Fatalf("matching-log options mismatch: %#v", options) + } + + if _, err := parseWaitForPausePointOptions([]string{"--id", "jump", "--matching-logs-max-count", "0"}); err == nil { + t.Fatalf("expected error for non-positive max count") + } +} + +// Verifies a hit response embeds marker-matching logs when requested. +func TestRunWaitForPausePointEmbedsMatchingLogsOnHit(t *testing.T) { + originalQuery := queryPausePointStatus + originalFetch := fetchMatchingLogs + defer func() { + queryPausePointStatus = originalQuery + fetchMatchingLogs = originalFetch + }() + + queryPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + return pausePointStatusResponse{Id: id, Status: pausePointStatusHit, IsHit: true, HitCount: 1}, nil + } + + fetchedSearchText := "" + fetchedMaxCount := 0 + fetchMatchingLogs = func( + ctx context.Context, + connection unityipc.Connection, + searchText string, + maxCount int, + ) ([]pausePointMatchingLog, error) { + fetchedSearchText = searchText + fetchedMaxCount = maxCount + return []pausePointMatchingLog{ + {Type: "Log", Message: "[jump] velocity=4.2"}, + {Type: "Log", Message: "[jump] grounded=false"}, + }, nil + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runWaitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ + id: "jump", + timeoutSeconds: 1, + timeout: time.Second, + includeMatchingLogs: true, + matchingLogsMaxCount: 5, + }, &stdout, &stderr) + + if code != 0 { + t.Fatalf("expected success, got %d with stderr %s", code, stderr.String()) + } + if fetchedSearchText != "jump" || fetchedMaxCount != 5 { + t.Fatalf("fetch arguments mismatch: %s %d", fetchedSearchText, fetchedMaxCount) + } + + result := pausePointWaitResult{} + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("stdout parse failed: %v from %s", err, stdout.String()) + } + if len(result.MatchingLogs) != 2 || result.MatchingLogs[0].Message != "[jump] velocity=4.2" { + t.Fatalf("matching logs mismatch: %#v", result.MatchingLogs) + } +} + +// Verifies the hit response stays unchanged when log embedding is not requested. +func TestRunWaitForPausePointOmitsMatchingLogsByDefault(t *testing.T) { + originalQuery := queryPausePointStatus + originalFetch := fetchMatchingLogs + defer func() { + queryPausePointStatus = originalQuery + fetchMatchingLogs = originalFetch + }() + + queryPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + return pausePointStatusResponse{Id: id, Status: pausePointStatusHit, IsHit: true, HitCount: 1}, nil + } + fetchMatchingLogs = func( + ctx context.Context, + connection unityipc.Connection, + searchText string, + maxCount int, + ) ([]pausePointMatchingLog, error) { + t.Fatalf("fetchMatchingLogs must not be called without the flag") + return nil, nil + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runWaitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ + id: "jump", + timeoutSeconds: 1, + timeout: time.Second, + }, &stdout, &stderr) + + if code != 0 { + t.Fatalf("expected success, got %d with stderr %s", code, stderr.String()) + } + if strings.Contains(stdout.String(), "MatchingLogs") { + t.Fatalf("MatchingLogs must be absent without the flag: %s", stdout.String()) + } +} + +// Verifies a log fetch failure never turns a successful hit into an error. +func TestRunWaitForPausePointIgnoresLogFetchFailure(t *testing.T) { + originalQuery := queryPausePointStatus + originalFetch := fetchMatchingLogs + defer func() { + queryPausePointStatus = originalQuery + fetchMatchingLogs = originalFetch + }() + + queryPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + return pausePointStatusResponse{Id: id, Status: pausePointStatusHit, IsHit: true, HitCount: 1}, nil + } + fetchMatchingLogs = func( + ctx context.Context, + connection unityipc.Connection, + searchText string, + maxCount int, + ) ([]pausePointMatchingLog, error) { + return nil, context.DeadlineExceeded + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runWaitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ + id: "jump", + timeoutSeconds: 1, + timeout: time.Second, + includeMatchingLogs: true, + matchingLogsMaxCount: pausePointDefaultLogsMaxCount, + }, &stdout, &stderr) + + if code != 0 { + t.Fatalf("expected success despite log fetch failure, got %d with stderr %s", code, stderr.String()) + } + if strings.Contains(stdout.String(), "MatchingLogs") { + t.Fatalf("MatchingLogs must be omitted when the fetch fails: %s", stdout.String()) + } +} + +// Verifies timeout envelopes embed marker-matching logs best-effort when requested. +func TestRunWaitForPausePointEmbedsMatchingLogsOnTimeout(t *testing.T) { + originalQuery := queryPausePointStatus + originalClear := clearPausePointStatus + originalFetch := fetchMatchingLogs + originalPoll := pausePointStatusPoll + pausePointStatusPoll = time.Millisecond + defer func() { + queryPausePointStatus = originalQuery + clearPausePointStatus = originalClear + fetchMatchingLogs = originalFetch + pausePointStatusPoll = originalPoll + }() + + queryPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + return pausePointStatusResponse{Id: id, Status: pausePointStatusEnabled, IsEnabled: true, IsPlaying: true}, nil + } + clearPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + return pausePointStatusResponse{Id: id, Status: pausePointStatusCleared}, nil + } + fetchMatchingLogs = func( + ctx context.Context, + connection unityipc.Connection, + searchText string, + maxCount int, + ) ([]pausePointMatchingLog, error) { + return []pausePointMatchingLog{{Type: "Log", Message: "[jump] never reached"}}, nil + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runWaitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ + id: "jump", + timeoutSeconds: 1, + timeout: 5 * time.Millisecond, + includeMatchingLogs: true, + matchingLogsMaxCount: pausePointDefaultLogsMaxCount, + }, &stdout, &stderr) + + if code != 1 { + t.Fatalf("expected timeout failure, got %d with stdout %s", code, stdout.String()) + } + envelope := parsePausePointErrorEnvelope(t, stderr.Bytes()) + matchingLogs, ok := envelope.Error.Details["matchingLogs"].([]any) + if !ok || len(matchingLogs) != 1 { + t.Fatalf("matchingLogs detail mismatch: %#v", envelope.Error.Details) + } +} + // Verifies timeout errors include a deterministic diagnosis hint for common stuck states. func TestPausePointTimeoutErrorIncludesDiagnosisHint(t *testing.T) { cases := []struct { From e0ebb06a0a0b7bae9a8322dd5b25e5b0d524e331 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 11 Jun 2026 01:24:23 +0900 Subject: [PATCH 05/31] Document matching-log embedding and timeout hints in the pause point skill Fold the new --include-matching-logs flag into the quick check template so the standard loop needs one less paused-Editor call, and point timeout debugging at error.details.hint and error.details.matchingLogs first. --- .agents/skills/uloop-wait-for-pause-point/SKILL.md | 13 +++++++------ .claude/skills/uloop-wait-for-pause-point/SKILL.md | 13 +++++++------ .../Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md | 13 +++++++------ 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/.agents/skills/uloop-wait-for-pause-point/SKILL.md b/.agents/skills/uloop-wait-for-pause-point/SKILL.md index 237fd01ed..f5202bf37 100644 --- a/.agents/skills/uloop-wait-for-pause-point/SKILL.md +++ b/.agents/skills/uloop-wait-for-pause-point/SKILL.md @@ -24,15 +24,16 @@ uloop enable-pause-point --id state-transition-applied --timeout-seconds 30 ``` 3. Trigger the action with a `simulate-*` command. -4. Run `uloop wait-for-pause-point --id state-transition-applied --timeout-seconds 30`, even if the trigger command already returned `InterruptedByPausePoint=true`. -5. Before resuming, read the focused log for the same marker id: +4. Wait for the marker and read the focused log in one call, even if the trigger command already returned `InterruptedByPausePoint=true`: ```bash -uloop get-logs --search-text state-transition-applied --max-count 20 +uloop wait-for-pause-point --id state-transition-applied --timeout-seconds 30 --include-matching-logs ``` -6. While Unity is still paused, capture any additional evidence with `uloop execute-dynamic-code`, `uloop get-hierarchy`, `uloop find-game-objects`, and one screenshot. -7. Clear the marker with `uloop clear-pause-point --id state-transition-applied` or stop PlayMode before moving on. +`--include-matching-logs` embeds the log entries matching the marker id as `MatchingLogs` in the hit response (`--matching-logs-max-count` adjusts the limit, default 10), so a separate `get-logs` call while paused is unnecessary. Without the flag, run `uloop get-logs --search-text state-transition-applied --max-count 20` before resuming instead. + +5. While Unity is still paused, capture any additional evidence with `uloop execute-dynamic-code`, `uloop get-hierarchy`, `uloop find-game-objects`, and one screenshot. +6. Clear the marker with `uloop clear-pause-point --id state-transition-applied` or stop PlayMode before moving on. ## When To Use @@ -47,7 +48,7 @@ uloop get-logs --search-text state-transition-applied --max-count 20 ## Timeout Checks -If this command times out, the marker line was not reached while the command waited. Inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, runtime conditions not being met, an id mismatch, or Unity already being paused. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. +If this command times out, the marker line was not reached while the command waited. Read `error.details.hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. Then inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, runtime conditions not being met, an id mismatch, or Unity already being paused. With `--include-matching-logs`, `error.details.matchingLogs` shows whether the marker's focused log ever appeared. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. Use `uloop pause-point-status --id state-transition-applied` only when you need to confirm the marker is armed or inspect the current hit state. Add focused debug logs before the marker when local variables must be captured. diff --git a/.claude/skills/uloop-wait-for-pause-point/SKILL.md b/.claude/skills/uloop-wait-for-pause-point/SKILL.md index 237fd01ed..f5202bf37 100644 --- a/.claude/skills/uloop-wait-for-pause-point/SKILL.md +++ b/.claude/skills/uloop-wait-for-pause-point/SKILL.md @@ -24,15 +24,16 @@ uloop enable-pause-point --id state-transition-applied --timeout-seconds 30 ``` 3. Trigger the action with a `simulate-*` command. -4. Run `uloop wait-for-pause-point --id state-transition-applied --timeout-seconds 30`, even if the trigger command already returned `InterruptedByPausePoint=true`. -5. Before resuming, read the focused log for the same marker id: +4. Wait for the marker and read the focused log in one call, even if the trigger command already returned `InterruptedByPausePoint=true`: ```bash -uloop get-logs --search-text state-transition-applied --max-count 20 +uloop wait-for-pause-point --id state-transition-applied --timeout-seconds 30 --include-matching-logs ``` -6. While Unity is still paused, capture any additional evidence with `uloop execute-dynamic-code`, `uloop get-hierarchy`, `uloop find-game-objects`, and one screenshot. -7. Clear the marker with `uloop clear-pause-point --id state-transition-applied` or stop PlayMode before moving on. +`--include-matching-logs` embeds the log entries matching the marker id as `MatchingLogs` in the hit response (`--matching-logs-max-count` adjusts the limit, default 10), so a separate `get-logs` call while paused is unnecessary. Without the flag, run `uloop get-logs --search-text state-transition-applied --max-count 20` before resuming instead. + +5. While Unity is still paused, capture any additional evidence with `uloop execute-dynamic-code`, `uloop get-hierarchy`, `uloop find-game-objects`, and one screenshot. +6. Clear the marker with `uloop clear-pause-point --id state-transition-applied` or stop PlayMode before moving on. ## When To Use @@ -47,7 +48,7 @@ uloop get-logs --search-text state-transition-applied --max-count 20 ## Timeout Checks -If this command times out, the marker line was not reached while the command waited. Inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, runtime conditions not being met, an id mismatch, or Unity already being paused. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. +If this command times out, the marker line was not reached while the command waited. Read `error.details.hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. Then inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, runtime conditions not being met, an id mismatch, or Unity already being paused. With `--include-matching-logs`, `error.details.matchingLogs` shows whether the marker's focused log ever appeared. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. Use `uloop pause-point-status --id state-transition-applied` only when you need to confirm the marker is armed or inspect the current hit state. Add focused debug logs before the marker when local variables must be captured. diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md index 237fd01ed..f5202bf37 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -24,15 +24,16 @@ uloop enable-pause-point --id state-transition-applied --timeout-seconds 30 ``` 3. Trigger the action with a `simulate-*` command. -4. Run `uloop wait-for-pause-point --id state-transition-applied --timeout-seconds 30`, even if the trigger command already returned `InterruptedByPausePoint=true`. -5. Before resuming, read the focused log for the same marker id: +4. Wait for the marker and read the focused log in one call, even if the trigger command already returned `InterruptedByPausePoint=true`: ```bash -uloop get-logs --search-text state-transition-applied --max-count 20 +uloop wait-for-pause-point --id state-transition-applied --timeout-seconds 30 --include-matching-logs ``` -6. While Unity is still paused, capture any additional evidence with `uloop execute-dynamic-code`, `uloop get-hierarchy`, `uloop find-game-objects`, and one screenshot. -7. Clear the marker with `uloop clear-pause-point --id state-transition-applied` or stop PlayMode before moving on. +`--include-matching-logs` embeds the log entries matching the marker id as `MatchingLogs` in the hit response (`--matching-logs-max-count` adjusts the limit, default 10), so a separate `get-logs` call while paused is unnecessary. Without the flag, run `uloop get-logs --search-text state-transition-applied --max-count 20` before resuming instead. + +5. While Unity is still paused, capture any additional evidence with `uloop execute-dynamic-code`, `uloop get-hierarchy`, `uloop find-game-objects`, and one screenshot. +6. Clear the marker with `uloop clear-pause-point --id state-transition-applied` or stop PlayMode before moving on. ## When To Use @@ -47,7 +48,7 @@ uloop get-logs --search-text state-transition-applied --max-count 20 ## Timeout Checks -If this command times out, the marker line was not reached while the command waited. Inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, runtime conditions not being met, an id mismatch, or Unity already being paused. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. +If this command times out, the marker line was not reached while the command waited. Read `error.details.hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. Then inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, runtime conditions not being met, an id mismatch, or Unity already being paused. With `--include-matching-logs`, `error.details.matchingLogs` shows whether the marker's focused log ever appeared. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. Use `uloop pause-point-status --id state-transition-applied` only when you need to confirm the marker is armed or inspect the current hit state. Add focused debug logs before the marker when local variables must be captured. From f420b07b37bfc9321738ef19f23b141a6614c3e9 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 11 Jun 2026 08:55:19 +0900 Subject: [PATCH 06/31] Add a diagnosis hint to pause point expired errors A marker whose enable-pause-point timeout window ends before the wait deadline surfaces as PAUSE_POINT_EXPIRED instead of a wait timeout, and that error carried no hint at all. An AI-agent E2E session hit exactly this: the agent saw repeated expired errors with no guidance and could not tell an id mismatch from an exhausted enable window. Reuse the PlayMode/paused diagnoses and add an expired-specific hint that points at the enable-side --timeout-seconds. --- cli/internal/cli/pause_point_errors.go | 32 ++++++++++++-- cli/internal/cli/pause_point_wait_test.go | 53 ++++++++++++++++++++--- 2 files changed, 75 insertions(+), 10 deletions(-) diff --git a/cli/internal/cli/pause_point_errors.go b/cli/internal/cli/pause_point_errors.go index 1beb91f04..c87c937f3 100644 --- a/cli/internal/cli/pause_point_errors.go +++ b/cli/internal/cli/pause_point_errors.go @@ -21,13 +21,18 @@ func pausePointWaitError( response, false) case pausePointWaitStateExpired: - return pausePointStateError( + expiredError := pausePointStateError( errorCodePausePointExpired, "Pause point expired before it was hit.", projectRoot, options, response, true) + hint := pausePointExpiredHint(response) + if hint != "" { + expiredError.Details["hint"] = hint + } + return expiredError case pausePointWaitStateCleared: return pausePointStateError( errorCodePausePointCleared, @@ -52,14 +57,19 @@ func pausePointWaitError( } } +const ( + pausePointHintPlayModeNotRunning = "PlayMode is not running. Start PlayMode (or trigger the marker code path in Edit Mode), then wait again." + pausePointHintEditorAlreadyPaused = "Unity is already paused, so gameplay cannot reach the marker. Resume PlayMode before waiting again." +) + // pausePointTimeoutHint maps the final probed status to a deterministic diagnosis, // because timeouts are where agents struggle to tell a missed code path from Editor state. func pausePointTimeoutHint(response pausePointStatusResponse) string { if !response.IsPlaying { - return "PlayMode is not running. Start PlayMode (or trigger the marker code path in Edit Mode), then wait again." + return pausePointHintPlayModeNotRunning } if response.IsPaused { - return "Unity is already paused, so gameplay cannot reach the marker. Resume PlayMode before waiting again." + return pausePointHintEditorAlreadyPaused } if response.HitCount == 0 && response.Status == pausePointStatusEnabled { return "Marker was enabled but never hit. Confirm the id matches UloopPausePoint.Pause(\"\") and that the code path was executed." @@ -67,6 +77,22 @@ func pausePointTimeoutHint(response pausePointStatusResponse) string { return "" } +// pausePointExpiredHint mirrors the timeout diagnosis for expired markers, because a marker +// whose enable window ends before the wait deadline surfaces as PAUSE_POINT_EXPIRED instead +// of a timeout and would otherwise carry no hint at all. +func pausePointExpiredHint(response pausePointStatusResponse) string { + if !response.IsPlaying { + return pausePointHintPlayModeNotRunning + } + if response.IsPaused { + return pausePointHintEditorAlreadyPaused + } + if response.HitCount == 0 { + return "Marker expired before it was hit: the enable-pause-point --timeout-seconds window (measured from enable, not from this wait) ran out. Re-enable the marker with a longer --timeout-seconds and trigger the code path again." + } + return "" +} + func pausePointStateError( errorCode string, message string, diff --git a/cli/internal/cli/pause_point_wait_test.go b/cli/internal/cli/pause_point_wait_test.go index 05a396e60..067e9b8e7 100644 --- a/cli/internal/cli/pause_point_wait_test.go +++ b/cli/internal/cli/pause_point_wait_test.go @@ -540,8 +540,47 @@ func TestPausePointTimeoutErrorIncludesDiagnosisHint(t *testing.T) { } } -// Verifies hints stay scoped to timeouts and to diagnosable response states. -func TestPausePointHintIsOmittedOutsideDiagnosableTimeouts(t *testing.T) { +// Verifies expired errors include a diagnosis hint, because a marker whose enable +// window ends before the wait deadline surfaces as PAUSE_POINT_EXPIRED, not a timeout. +func TestPausePointExpiredErrorIncludesDiagnosisHint(t *testing.T) { + cases := []struct { + name string + response pausePointStatusResponse + wantHint string + }{ + { + name: "play mode not running", + response: pausePointStatusResponse{Id: "jump", Status: pausePointStatusExpired, IsPlaying: false}, + wantHint: "PlayMode is not running. Start PlayMode (or trigger the marker code path in Edit Mode), then wait again.", + }, + { + name: "editor already paused", + response: pausePointStatusResponse{Id: "jump", Status: pausePointStatusExpired, IsPlaying: true, IsPaused: true}, + wantHint: "Unity is already paused, so gameplay cannot reach the marker. Resume PlayMode before waiting again.", + }, + { + name: "marker expired before hit", + response: pausePointStatusResponse{Id: "jump", Status: pausePointStatusExpired, IsPlaying: true, HitCount: 0}, + wantHint: "Marker expired before it was hit: the enable-pause-point --timeout-seconds window (measured from enable, not from this wait) ran out. Re-enable the marker with a longer --timeout-seconds and trigger the code path again.", + }, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + cliErr := pausePointWaitError("/tmp/MyProject", waitForPausePointOptions{ + id: "jump", + timeoutSeconds: 1, + }, testCase.response, pausePointWaitStateExpired) + + if cliErr.Details["hint"] != testCase.wantHint { + t.Fatalf("hint mismatch: %#v", cliErr.Details) + } + }) + } +} + +// Verifies hints stay scoped to diagnosable response states. +func TestPausePointHintIsOmittedOutsideDiagnosableStates(t *testing.T) { hitResponse := pausePointStatusResponse{Id: "jump", Status: pausePointStatusHit, IsPlaying: true, HitCount: 1} timeoutErr := pausePointWaitError("/tmp/MyProject", waitForPausePointOptions{ id: "jump", @@ -551,13 +590,13 @@ func TestPausePointHintIsOmittedOutsideDiagnosableTimeouts(t *testing.T) { t.Fatalf("hint should be omitted when no diagnosis applies: %#v", timeoutErr.Details) } - notPlayingResponse := pausePointStatusResponse{Id: "jump", Status: pausePointStatusExpired, IsPlaying: false} - expiredErr := pausePointWaitError("/tmp/MyProject", waitForPausePointOptions{ + clearedResponse := pausePointStatusResponse{Id: "jump", Status: pausePointStatusCleared, IsPlaying: true} + clearedErr := pausePointWaitError("/tmp/MyProject", waitForPausePointOptions{ id: "jump", timeoutSeconds: 1, - }, notPlayingResponse, pausePointWaitStateExpired) - if _, exists := expiredErr.Details["hint"]; exists { - t.Fatalf("hint should be omitted for non-timeout states: %#v", expiredErr.Details) + }, clearedResponse, pausePointWaitStateCleared) + if _, exists := clearedErr.Details["hint"]; exists { + t.Fatalf("hint should be omitted for cleared markers: %#v", clearedErr.Details) } } From a7ec5c33e7f6d1785e1ad1defa07770dcbef9ef3 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 11 Jun 2026 08:55:27 +0900 Subject: [PATCH 07/31] Document clear-pause-point --all and the expired-error hint in the skill Two AI agents independently asked for a clear-all command that already exists, so the discoverability gap is in the skill doc, not the CLI. Also explain that PAUSE_POINT_EXPIRED now carries the same diagnosis hint and what an expired marker means for the enable-side timeout. --- .agents/skills/uloop-wait-for-pause-point/SKILL.md | 4 ++-- .claude/skills/uloop-wait-for-pause-point/SKILL.md | 4 ++-- Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.agents/skills/uloop-wait-for-pause-point/SKILL.md b/.agents/skills/uloop-wait-for-pause-point/SKILL.md index f5202bf37..dbdb32e22 100644 --- a/.agents/skills/uloop-wait-for-pause-point/SKILL.md +++ b/.agents/skills/uloop-wait-for-pause-point/SKILL.md @@ -33,7 +33,7 @@ uloop wait-for-pause-point --id state-transition-applied --timeout-seconds 30 -- `--include-matching-logs` embeds the log entries matching the marker id as `MatchingLogs` in the hit response (`--matching-logs-max-count` adjusts the limit, default 10), so a separate `get-logs` call while paused is unnecessary. Without the flag, run `uloop get-logs --search-text state-transition-applied --max-count 20` before resuming instead. 5. While Unity is still paused, capture any additional evidence with `uloop execute-dynamic-code`, `uloop get-hierarchy`, `uloop find-game-objects`, and one screenshot. -6. Clear the marker with `uloop clear-pause-point --id state-transition-applied` or stop PlayMode before moving on. +6. Clear the marker with `uloop clear-pause-point --id state-transition-applied` or stop PlayMode before moving on. Use `uloop clear-pause-point --all` to clear every active marker at once, for example when resetting between E2E scenarios. ## When To Use @@ -48,7 +48,7 @@ uloop wait-for-pause-point --id state-transition-applied --timeout-seconds 30 -- ## Timeout Checks -If this command times out, the marker line was not reached while the command waited. Read `error.details.hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. Then inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, runtime conditions not being met, an id mismatch, or Unity already being paused. With `--include-matching-logs`, `error.details.matchingLogs` shows whether the marker's focused log ever appeared. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. +If this command times out, the marker line was not reached while the command waited. Read `error.details.hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. A `PAUSE_POINT_EXPIRED` error carries the same hint: it means the marker's own `enable-pause-point --timeout-seconds` window (measured from enable, not from wait) ran out first, so re-enable the marker with a longer timeout. Then inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, runtime conditions not being met, an id mismatch, or Unity already being paused. With `--include-matching-logs`, `error.details.matchingLogs` shows whether the marker's focused log ever appeared. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. Use `uloop pause-point-status --id state-transition-applied` only when you need to confirm the marker is armed or inspect the current hit state. Add focused debug logs before the marker when local variables must be captured. diff --git a/.claude/skills/uloop-wait-for-pause-point/SKILL.md b/.claude/skills/uloop-wait-for-pause-point/SKILL.md index f5202bf37..dbdb32e22 100644 --- a/.claude/skills/uloop-wait-for-pause-point/SKILL.md +++ b/.claude/skills/uloop-wait-for-pause-point/SKILL.md @@ -33,7 +33,7 @@ uloop wait-for-pause-point --id state-transition-applied --timeout-seconds 30 -- `--include-matching-logs` embeds the log entries matching the marker id as `MatchingLogs` in the hit response (`--matching-logs-max-count` adjusts the limit, default 10), so a separate `get-logs` call while paused is unnecessary. Without the flag, run `uloop get-logs --search-text state-transition-applied --max-count 20` before resuming instead. 5. While Unity is still paused, capture any additional evidence with `uloop execute-dynamic-code`, `uloop get-hierarchy`, `uloop find-game-objects`, and one screenshot. -6. Clear the marker with `uloop clear-pause-point --id state-transition-applied` or stop PlayMode before moving on. +6. Clear the marker with `uloop clear-pause-point --id state-transition-applied` or stop PlayMode before moving on. Use `uloop clear-pause-point --all` to clear every active marker at once, for example when resetting between E2E scenarios. ## When To Use @@ -48,7 +48,7 @@ uloop wait-for-pause-point --id state-transition-applied --timeout-seconds 30 -- ## Timeout Checks -If this command times out, the marker line was not reached while the command waited. Read `error.details.hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. Then inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, runtime conditions not being met, an id mismatch, or Unity already being paused. With `--include-matching-logs`, `error.details.matchingLogs` shows whether the marker's focused log ever appeared. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. +If this command times out, the marker line was not reached while the command waited. Read `error.details.hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. A `PAUSE_POINT_EXPIRED` error carries the same hint: it means the marker's own `enable-pause-point --timeout-seconds` window (measured from enable, not from wait) ran out first, so re-enable the marker with a longer timeout. Then inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, runtime conditions not being met, an id mismatch, or Unity already being paused. With `--include-matching-logs`, `error.details.matchingLogs` shows whether the marker's focused log ever appeared. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. Use `uloop pause-point-status --id state-transition-applied` only when you need to confirm the marker is armed or inspect the current hit state. Add focused debug logs before the marker when local variables must be captured. diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md index f5202bf37..dbdb32e22 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -33,7 +33,7 @@ uloop wait-for-pause-point --id state-transition-applied --timeout-seconds 30 -- `--include-matching-logs` embeds the log entries matching the marker id as `MatchingLogs` in the hit response (`--matching-logs-max-count` adjusts the limit, default 10), so a separate `get-logs` call while paused is unnecessary. Without the flag, run `uloop get-logs --search-text state-transition-applied --max-count 20` before resuming instead. 5. While Unity is still paused, capture any additional evidence with `uloop execute-dynamic-code`, `uloop get-hierarchy`, `uloop find-game-objects`, and one screenshot. -6. Clear the marker with `uloop clear-pause-point --id state-transition-applied` or stop PlayMode before moving on. +6. Clear the marker with `uloop clear-pause-point --id state-transition-applied` or stop PlayMode before moving on. Use `uloop clear-pause-point --all` to clear every active marker at once, for example when resetting between E2E scenarios. ## When To Use @@ -48,7 +48,7 @@ uloop wait-for-pause-point --id state-transition-applied --timeout-seconds 30 -- ## Timeout Checks -If this command times out, the marker line was not reached while the command waited. Read `error.details.hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. Then inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, runtime conditions not being met, an id mismatch, or Unity already being paused. With `--include-matching-logs`, `error.details.matchingLogs` shows whether the marker's focused log ever appeared. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. +If this command times out, the marker line was not reached while the command waited. Read `error.details.hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. A `PAUSE_POINT_EXPIRED` error carries the same hint: it means the marker's own `enable-pause-point --timeout-seconds` window (measured from enable, not from wait) ran out first, so re-enable the marker with a longer timeout. Then inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, runtime conditions not being met, an id mismatch, or Unity already being paused. With `--include-matching-logs`, `error.details.matchingLogs` shows whether the marker's focused log ever appeared. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. Use `uloop pause-point-status --id state-transition-applied` only when you need to confirm the marker is armed or inspect the current hit state. Add focused debug logs before the marker when local variables must be captured. From f2ecb521052e4f42f5c481727591de207b8d782c Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 11 Jun 2026 10:06:03 +0900 Subject: [PATCH 08/31] Always embed matching logs in wait-for-pause-point responses In two supervised AI-agent E2E sessions, agents attached --include-matching-logs to nearly every wait call (44 of ~57 waits) and one explicitly asked for it to be the default, so the opt-in flag only added friction. Retire the flag, keep --matching-logs-max-count, and make the empty/absent distinction explicit: an empty MatchingLogs array means no matching log exists, while an absent field means the best-effort fetch itself failed. --- .../uloop-wait-for-pause-point/SKILL.md | 6 +-- .../uloop-wait-for-pause-point/SKILL.md | 6 +-- .../CliOnlyTools~/PausePoint/Skill/SKILL.md | 6 +-- cli/internal/cli/completion_options.go | 1 - cli/internal/cli/pause_point_logs.go | 1 - cli/internal/cli/pause_point_wait.go | 21 ++++------ cli/internal/cli/pause_point_wait_test.go | 39 ++++++++++--------- 7 files changed, 37 insertions(+), 43 deletions(-) diff --git a/.agents/skills/uloop-wait-for-pause-point/SKILL.md b/.agents/skills/uloop-wait-for-pause-point/SKILL.md index dbdb32e22..e5d825418 100644 --- a/.agents/skills/uloop-wait-for-pause-point/SKILL.md +++ b/.agents/skills/uloop-wait-for-pause-point/SKILL.md @@ -27,10 +27,10 @@ uloop enable-pause-point --id state-transition-applied --timeout-seconds 30 4. Wait for the marker and read the focused log in one call, even if the trigger command already returned `InterruptedByPausePoint=true`: ```bash -uloop wait-for-pause-point --id state-transition-applied --timeout-seconds 30 --include-matching-logs +uloop wait-for-pause-point --id state-transition-applied --timeout-seconds 30 ``` -`--include-matching-logs` embeds the log entries matching the marker id as `MatchingLogs` in the hit response (`--matching-logs-max-count` adjusts the limit, default 10), so a separate `get-logs` call while paused is unnecessary. Without the flag, run `uloop get-logs --search-text state-transition-applied --max-count 20` before resuming instead. +The hit response always embeds the log entries matching the marker id as `MatchingLogs` (`--matching-logs-max-count` adjusts the limit, default 10), so a separate `get-logs` call while paused is unnecessary. An empty `MatchingLogs` array means the fetch succeeded and no matching log exists; if the field is absent, the log fetch itself failed, so fall back to `uloop get-logs --search-text state-transition-applied --max-count 20` while paused. 5. While Unity is still paused, capture any additional evidence with `uloop execute-dynamic-code`, `uloop get-hierarchy`, `uloop find-game-objects`, and one screenshot. 6. Clear the marker with `uloop clear-pause-point --id state-transition-applied` or stop PlayMode before moving on. Use `uloop clear-pause-point --all` to clear every active marker at once, for example when resetting between E2E scenarios. @@ -48,7 +48,7 @@ uloop wait-for-pause-point --id state-transition-applied --timeout-seconds 30 -- ## Timeout Checks -If this command times out, the marker line was not reached while the command waited. Read `error.details.hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. A `PAUSE_POINT_EXPIRED` error carries the same hint: it means the marker's own `enable-pause-point --timeout-seconds` window (measured from enable, not from wait) ran out first, so re-enable the marker with a longer timeout. Then inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, runtime conditions not being met, an id mismatch, or Unity already being paused. With `--include-matching-logs`, `error.details.matchingLogs` shows whether the marker's focused log ever appeared. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. +If this command times out, the marker line was not reached while the command waited. Read `error.details.hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. A `PAUSE_POINT_EXPIRED` error carries the same hint: it means the marker's own `enable-pause-point --timeout-seconds` window (measured from enable, not from wait) ran out first, so re-enable the marker with a longer timeout. Then inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, runtime conditions not being met, an id mismatch, or Unity already being paused. `error.details.matchingLogs` shows whether the marker's focused log ever appeared. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. Use `uloop pause-point-status --id state-transition-applied` only when you need to confirm the marker is armed or inspect the current hit state. Add focused debug logs before the marker when local variables must be captured. diff --git a/.claude/skills/uloop-wait-for-pause-point/SKILL.md b/.claude/skills/uloop-wait-for-pause-point/SKILL.md index dbdb32e22..e5d825418 100644 --- a/.claude/skills/uloop-wait-for-pause-point/SKILL.md +++ b/.claude/skills/uloop-wait-for-pause-point/SKILL.md @@ -27,10 +27,10 @@ uloop enable-pause-point --id state-transition-applied --timeout-seconds 30 4. Wait for the marker and read the focused log in one call, even if the trigger command already returned `InterruptedByPausePoint=true`: ```bash -uloop wait-for-pause-point --id state-transition-applied --timeout-seconds 30 --include-matching-logs +uloop wait-for-pause-point --id state-transition-applied --timeout-seconds 30 ``` -`--include-matching-logs` embeds the log entries matching the marker id as `MatchingLogs` in the hit response (`--matching-logs-max-count` adjusts the limit, default 10), so a separate `get-logs` call while paused is unnecessary. Without the flag, run `uloop get-logs --search-text state-transition-applied --max-count 20` before resuming instead. +The hit response always embeds the log entries matching the marker id as `MatchingLogs` (`--matching-logs-max-count` adjusts the limit, default 10), so a separate `get-logs` call while paused is unnecessary. An empty `MatchingLogs` array means the fetch succeeded and no matching log exists; if the field is absent, the log fetch itself failed, so fall back to `uloop get-logs --search-text state-transition-applied --max-count 20` while paused. 5. While Unity is still paused, capture any additional evidence with `uloop execute-dynamic-code`, `uloop get-hierarchy`, `uloop find-game-objects`, and one screenshot. 6. Clear the marker with `uloop clear-pause-point --id state-transition-applied` or stop PlayMode before moving on. Use `uloop clear-pause-point --all` to clear every active marker at once, for example when resetting between E2E scenarios. @@ -48,7 +48,7 @@ uloop wait-for-pause-point --id state-transition-applied --timeout-seconds 30 -- ## Timeout Checks -If this command times out, the marker line was not reached while the command waited. Read `error.details.hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. A `PAUSE_POINT_EXPIRED` error carries the same hint: it means the marker's own `enable-pause-point --timeout-seconds` window (measured from enable, not from wait) ran out first, so re-enable the marker with a longer timeout. Then inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, runtime conditions not being met, an id mismatch, or Unity already being paused. With `--include-matching-logs`, `error.details.matchingLogs` shows whether the marker's focused log ever appeared. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. +If this command times out, the marker line was not reached while the command waited. Read `error.details.hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. A `PAUSE_POINT_EXPIRED` error carries the same hint: it means the marker's own `enable-pause-point --timeout-seconds` window (measured from enable, not from wait) ran out first, so re-enable the marker with a longer timeout. Then inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, runtime conditions not being met, an id mismatch, or Unity already being paused. `error.details.matchingLogs` shows whether the marker's focused log ever appeared. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. Use `uloop pause-point-status --id state-transition-applied` only when you need to confirm the marker is armed or inspect the current hit state. Add focused debug logs before the marker when local variables must be captured. diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md index dbdb32e22..e5d825418 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -27,10 +27,10 @@ uloop enable-pause-point --id state-transition-applied --timeout-seconds 30 4. Wait for the marker and read the focused log in one call, even if the trigger command already returned `InterruptedByPausePoint=true`: ```bash -uloop wait-for-pause-point --id state-transition-applied --timeout-seconds 30 --include-matching-logs +uloop wait-for-pause-point --id state-transition-applied --timeout-seconds 30 ``` -`--include-matching-logs` embeds the log entries matching the marker id as `MatchingLogs` in the hit response (`--matching-logs-max-count` adjusts the limit, default 10), so a separate `get-logs` call while paused is unnecessary. Without the flag, run `uloop get-logs --search-text state-transition-applied --max-count 20` before resuming instead. +The hit response always embeds the log entries matching the marker id as `MatchingLogs` (`--matching-logs-max-count` adjusts the limit, default 10), so a separate `get-logs` call while paused is unnecessary. An empty `MatchingLogs` array means the fetch succeeded and no matching log exists; if the field is absent, the log fetch itself failed, so fall back to `uloop get-logs --search-text state-transition-applied --max-count 20` while paused. 5. While Unity is still paused, capture any additional evidence with `uloop execute-dynamic-code`, `uloop get-hierarchy`, `uloop find-game-objects`, and one screenshot. 6. Clear the marker with `uloop clear-pause-point --id state-transition-applied` or stop PlayMode before moving on. Use `uloop clear-pause-point --all` to clear every active marker at once, for example when resetting between E2E scenarios. @@ -48,7 +48,7 @@ uloop wait-for-pause-point --id state-transition-applied --timeout-seconds 30 -- ## Timeout Checks -If this command times out, the marker line was not reached while the command waited. Read `error.details.hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. A `PAUSE_POINT_EXPIRED` error carries the same hint: it means the marker's own `enable-pause-point --timeout-seconds` window (measured from enable, not from wait) ran out first, so re-enable the marker with a longer timeout. Then inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, runtime conditions not being met, an id mismatch, or Unity already being paused. With `--include-matching-logs`, `error.details.matchingLogs` shows whether the marker's focused log ever appeared. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. +If this command times out, the marker line was not reached while the command waited. Read `error.details.hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. A `PAUSE_POINT_EXPIRED` error carries the same hint: it means the marker's own `enable-pause-point --timeout-seconds` window (measured from enable, not from wait) ran out first, so re-enable the marker with a longer timeout. Then inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, runtime conditions not being met, an id mismatch, or Unity already being paused. `error.details.matchingLogs` shows whether the marker's focused log ever appeared. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. Use `uloop pause-point-status --id state-transition-applied` only when you need to confirm the marker is armed or inspect the current hit state. Add focused debug logs before the marker when local variables must be captured. diff --git a/cli/internal/cli/completion_options.go b/cli/internal/cli/completion_options.go index b3447a29f..e243839c6 100644 --- a/cli/internal/cli/completion_options.go +++ b/cli/internal/cli/completion_options.go @@ -15,7 +15,6 @@ var nativeCommandOptions = map[string][]string{ pausePointWaitCommandName: { "--" + pausePointIDFlagName, "--" + pausePointTimeoutFlagName, - "--" + pausePointIncludeLogsFlagName, "--" + pausePointLogsMaxCountFlagName, "--" + projectPathFlagName, }, diff --git a/cli/internal/cli/pause_point_logs.go b/cli/internal/cli/pause_point_logs.go index 553b6d80a..c9ec24bc7 100644 --- a/cli/internal/cli/pause_point_logs.go +++ b/cli/internal/cli/pause_point_logs.go @@ -8,7 +8,6 @@ import ( ) const ( - pausePointIncludeLogsFlagName = "include-matching-logs" pausePointLogsMaxCountFlagName = "matching-logs-max-count" pausePointDefaultLogsMaxCount = 10 pausePointGetLogsCommandName = "get-logs" diff --git a/cli/internal/cli/pause_point_wait.go b/cli/internal/cli/pause_point_wait.go index 2e366c6e9..4fbf96507 100644 --- a/cli/internal/cli/pause_point_wait.go +++ b/cli/internal/cli/pause_point_wait.go @@ -38,7 +38,6 @@ type waitForPausePointOptions struct { id string timeoutSeconds int timeout time.Duration - includeMatchingLogs bool matchingLogsMaxCount int } @@ -146,13 +145,13 @@ func runWaitForPausePoint( } if state == pausePointWaitStateHit { + // 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". var payload any = response - if options.includeMatchingLogs { - // Best-effort: a hit must stay a success even if Unity is busy while paused. - logs, logsErr := fetchMatchingLogs(ctx, connection, options.id, options.matchingLogsMaxCount) - if logsErr == nil { - payload = pausePointWaitResult{pausePointStatusResponse: response, MatchingLogs: logs} - } + logs, logsErr := fetchMatchingLogs(ctx, connection, options.id, options.matchingLogsMaxCount) + if logsErr == nil { + payload = pausePointWaitResult{pausePointStatusResponse: response, MatchingLogs: logs} } result, marshalErr := json.Marshal(payload) if marshalErr != nil { @@ -172,7 +171,7 @@ func runWaitForPausePoint( } waitErr := pausePointWaitError(connection.ProjectRoot, options, response, state) - if options.includeMatchingLogs && state == pausePointWaitStateTimeout { + if state == pausePointWaitStateTimeout { // Best-effort: the timeout diagnosis must not depend on a second Unity round trip succeeding. logs, logsErr := fetchMatchingLogs(ctx, connection, options.id, options.matchingLogsMaxCount) if logsErr == nil { @@ -192,12 +191,6 @@ func parseWaitForPausePointOptions(args []string) (waitForPausePointOptions, err for index := 0; index < len(args); index++ { arg := args[index] - // The include flag is a bare boolean; parseFlagValue would demand a value for it. - if arg == "--"+pausePointIncludeLogsFlagName { - options.includeMatchingLogs = true - continue - } - name, value, consumedNext, err := parseFlagValue(arg, args, index) if err != nil { return waitForPausePointOptions{}, err diff --git a/cli/internal/cli/pause_point_wait_test.go b/cli/internal/cli/pause_point_wait_test.go index 067e9b8e7..9f53c709c 100644 --- a/cli/internal/cli/pause_point_wait_test.go +++ b/cli/internal/cli/pause_point_wait_test.go @@ -276,32 +276,37 @@ func TestRunWaitForPausePointReportsNotEnabledError(t *testing.T) { } } -// Verifies matching-log flags parse with safe defaults and reject non-positive counts. +// Verifies the matching-log count flag parses with a safe default and rejects non-positive counts. func TestParseWaitForPausePointOptionsParsesMatchingLogFlags(t *testing.T) { defaults, err := parseWaitForPausePointOptions([]string{"--id", "jump"}) if err != nil { t.Fatalf("default parse failed: %v", err) } - if defaults.includeMatchingLogs || defaults.matchingLogsMaxCount != pausePointDefaultLogsMaxCount { + if defaults.matchingLogsMaxCount != pausePointDefaultLogsMaxCount { t.Fatalf("default matching-log options mismatch: %#v", defaults) } options, err := parseWaitForPausePointOptions([]string{ - "--id", "jump", "--include-matching-logs", "--matching-logs-max-count", "5", + "--id", "jump", "--matching-logs-max-count", "5", }) if err != nil { t.Fatalf("parse failed: %v", err) } - if !options.includeMatchingLogs || options.matchingLogsMaxCount != 5 { + if options.matchingLogsMaxCount != 5 { t.Fatalf("matching-log options mismatch: %#v", options) } if _, err := parseWaitForPausePointOptions([]string{"--id", "jump", "--matching-logs-max-count", "0"}); err == nil { t.Fatalf("expected error for non-positive max count") } + + // Log embedding is always on, so the retired opt-in flag must be rejected as unknown. + if _, err := parseWaitForPausePointOptions([]string{"--id", "jump", "--include-matching-logs"}); err == nil { + t.Fatalf("expected error for the retired include flag") + } } -// Verifies a hit response embeds marker-matching logs when requested. +// Verifies a hit response always embeds marker-matching logs. func TestRunWaitForPausePointEmbedsMatchingLogsOnHit(t *testing.T) { originalQuery := queryPausePointStatus originalFetch := fetchMatchingLogs @@ -340,7 +345,6 @@ func TestRunWaitForPausePointEmbedsMatchingLogsOnHit(t *testing.T) { id: "jump", timeoutSeconds: 1, timeout: time.Second, - includeMatchingLogs: true, matchingLogsMaxCount: 5, }, &stdout, &stderr) @@ -360,8 +364,9 @@ func TestRunWaitForPausePointEmbedsMatchingLogsOnHit(t *testing.T) { } } -// Verifies the hit response stays unchanged when log embedding is not requested. -func TestRunWaitForPausePointOmitsMatchingLogsByDefault(t *testing.T) { +// Verifies a successful fetch with zero matches yields an explicit empty MatchingLogs array, +// so agents can tell "no matching log appeared" apart from "log fetch failed" (field absent). +func TestRunWaitForPausePointEmbedsEmptyMatchingLogsWhenNoneMatch(t *testing.T) { originalQuery := queryPausePointStatus originalFetch := fetchMatchingLogs defer func() { @@ -382,23 +387,23 @@ func TestRunWaitForPausePointOmitsMatchingLogsByDefault(t *testing.T) { searchText string, maxCount int, ) ([]pausePointMatchingLog, error) { - t.Fatalf("fetchMatchingLogs must not be called without the flag") - return nil, nil + return []pausePointMatchingLog{}, nil } var stdout bytes.Buffer var stderr bytes.Buffer code := runWaitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ - id: "jump", - timeoutSeconds: 1, - timeout: time.Second, + id: "jump", + timeoutSeconds: 1, + timeout: time.Second, + matchingLogsMaxCount: pausePointDefaultLogsMaxCount, }, &stdout, &stderr) if code != 0 { t.Fatalf("expected success, got %d with stderr %s", code, stderr.String()) } - if strings.Contains(stdout.String(), "MatchingLogs") { - t.Fatalf("MatchingLogs must be absent without the flag: %s", stdout.String()) + if !strings.Contains(stdout.String(), "\"MatchingLogs\": []") { + t.Fatalf("MatchingLogs must be an explicit empty array: %s", stdout.String()) } } @@ -433,7 +438,6 @@ func TestRunWaitForPausePointIgnoresLogFetchFailure(t *testing.T) { id: "jump", timeoutSeconds: 1, timeout: time.Second, - includeMatchingLogs: true, matchingLogsMaxCount: pausePointDefaultLogsMaxCount, }, &stdout, &stderr) @@ -445,7 +449,7 @@ func TestRunWaitForPausePointIgnoresLogFetchFailure(t *testing.T) { } } -// Verifies timeout envelopes embed marker-matching logs best-effort when requested. +// Verifies timeout envelopes always embed marker-matching logs best-effort. func TestRunWaitForPausePointEmbedsMatchingLogsOnTimeout(t *testing.T) { originalQuery := queryPausePointStatus originalClear := clearPausePointStatus @@ -488,7 +492,6 @@ func TestRunWaitForPausePointEmbedsMatchingLogsOnTimeout(t *testing.T) { id: "jump", timeoutSeconds: 1, timeout: 5 * time.Millisecond, - includeMatchingLogs: true, matchingLogsMaxCount: pausePointDefaultLogsMaxCount, }, &stdout, &stderr) From 5aeca463f0fc2e3c182b7f8c2429a027a020379c Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 11 Jun 2026 14:23:19 +0900 Subject: [PATCH 09/31] Unify MatchingLogs naming across hit responses and timeout details The timeout error detail used camelCase matchingLogs while the hit response used PascalCase MatchingLogs for the same data, which an AI-agent E2E session flagged as confusing. One spelling now covers both surfaces. Also state explicitly in the skill that log embedding is always on and the old opt-in flag no longer exists, because all three agents tripped over stale flag docs before self-recovering. --- Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md | 4 ++-- cli/internal/cli/pause_point_wait.go | 2 +- cli/internal/cli/pause_point_wait_test.go | 5 +++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md index e5d825418..24cac1f99 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -30,7 +30,7 @@ uloop enable-pause-point --id state-transition-applied --timeout-seconds 30 uloop wait-for-pause-point --id state-transition-applied --timeout-seconds 30 ``` -The hit response always embeds the log entries matching the marker id as `MatchingLogs` (`--matching-logs-max-count` adjusts the limit, default 10), so a separate `get-logs` call while paused is unnecessary. An empty `MatchingLogs` array means the fetch succeeded and no matching log exists; if the field is absent, the log fetch itself failed, so fall back to `uloop get-logs --search-text state-transition-applied --max-count 20` while paused. +The hit response always embeds the log entries matching the marker id as `MatchingLogs` (`--matching-logs-max-count` adjusts the limit, default 10), so a separate `get-logs` call while paused is unnecessary. Log embedding is always on; there is no opt-in flag, and a `--include-matching-logs` option no longer exists. An empty `MatchingLogs` array means the fetch succeeded and no matching log exists; if the field is absent, the log fetch itself failed, so fall back to `uloop get-logs --search-text state-transition-applied --max-count 20` while paused. 5. While Unity is still paused, capture any additional evidence with `uloop execute-dynamic-code`, `uloop get-hierarchy`, `uloop find-game-objects`, and one screenshot. 6. Clear the marker with `uloop clear-pause-point --id state-transition-applied` or stop PlayMode before moving on. Use `uloop clear-pause-point --all` to clear every active marker at once, for example when resetting between E2E scenarios. @@ -48,7 +48,7 @@ The hit response always embeds the log entries matching the marker id as `Matchi ## Timeout Checks -If this command times out, the marker line was not reached while the command waited. Read `error.details.hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. A `PAUSE_POINT_EXPIRED` error carries the same hint: it means the marker's own `enable-pause-point --timeout-seconds` window (measured from enable, not from wait) ran out first, so re-enable the marker with a longer timeout. Then inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, runtime conditions not being met, an id mismatch, or Unity already being paused. `error.details.matchingLogs` shows whether the marker's focused log ever appeared. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. +If this command times out, the marker line was not reached while the command waited. Read `error.details.hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. A `PAUSE_POINT_EXPIRED` error carries the same hint: it means the marker's own `enable-pause-point --timeout-seconds` window (measured from enable, not from wait) ran out first, so re-enable the marker with a longer timeout. Then inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, runtime conditions not being met, an id mismatch, or Unity already being paused. `error.details.MatchingLogs` shows whether the marker's focused log ever appeared. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. Use `uloop pause-point-status --id state-transition-applied` only when you need to confirm the marker is armed or inspect the current hit state. Add focused debug logs before the marker when local variables must be captured. diff --git a/cli/internal/cli/pause_point_wait.go b/cli/internal/cli/pause_point_wait.go index 4fbf96507..e9ea6724b 100644 --- a/cli/internal/cli/pause_point_wait.go +++ b/cli/internal/cli/pause_point_wait.go @@ -175,7 +175,7 @@ func runWaitForPausePoint( // Best-effort: the timeout diagnosis must not depend on a second Unity round trip succeeding. logs, logsErr := fetchMatchingLogs(ctx, connection, options.id, options.matchingLogsMaxCount) if logsErr == nil { - waitErr.Details["matchingLogs"] = logs + waitErr.Details["MatchingLogs"] = logs } } writeErrorEnvelope(stderr, waitErr) diff --git a/cli/internal/cli/pause_point_wait_test.go b/cli/internal/cli/pause_point_wait_test.go index 9f53c709c..f4438b742 100644 --- a/cli/internal/cli/pause_point_wait_test.go +++ b/cli/internal/cli/pause_point_wait_test.go @@ -499,9 +499,10 @@ func TestRunWaitForPausePointEmbedsMatchingLogsOnTimeout(t *testing.T) { t.Fatalf("expected timeout failure, got %d with stdout %s", code, stdout.String()) } envelope := parsePausePointErrorEnvelope(t, stderr.Bytes()) - matchingLogs, ok := envelope.Error.Details["matchingLogs"].([]any) + // The detail key mirrors the hit-response field name, so one spelling covers both surfaces. + matchingLogs, ok := envelope.Error.Details["MatchingLogs"].([]any) if !ok || len(matchingLogs) != 1 { - t.Fatalf("matchingLogs detail mismatch: %#v", envelope.Error.Details) + t.Fatalf("MatchingLogs detail mismatch: %#v", envelope.Error.Details) } } From d06e07b2b40844051063dbefcec833b74772fbb9 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 11 Jun 2026 14:23:19 +0900 Subject: [PATCH 10/31] Fall back to a project IPC probe when the launch process scan fails Codex-sandboxed agents cannot exec /bin/ps, so launch died at process detection even though Unity was running and every other command worked. All three supervised agents had to discover by hand that the Editor was reachable. When the scan errors on a plain launch, probe the project IPC instead: a response proves Unity is running, so report AlreadyRunning with an unfocused-window note. Restart and quit still fail because killing requires a process id. --- .../CliOnlyTools~/Launch/Skill/SKILL.md | 1 + cli/internal/cli/launch.go | 7 ++ cli/internal/cli/launch_ready.go | 17 ++++ cli/internal/cli/launch_test.go | 94 +++++++++++++++++++ 4 files changed, 119 insertions(+) diff --git a/Packages/src/Editor/CliOnlyTools~/Launch/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/Launch/Skill/SKILL.md index 3530881aa..7d6b06e1f 100644 --- a/Packages/src/Editor/CliOnlyTools~/Launch/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/Launch/Skill/SKILL.md @@ -50,6 +50,7 @@ uloop launch --quit - Prints detected Unity version - Prints project path - If Unity is already running, focuses the existing window and verifies tool readiness +- If the process scan is blocked by the environment (e.g. sandboxed `ps`), plain launch falls back to probing the project IPC; when Unity responds it reports `AlreadyRunning: true` without focusing the window instead of failing. `--restart` and `--quit` still fail because they need the process id - If launching or restarting, prints when it is waiting for Unity CLI Loop server readiness - If launching or restarting, waits until Unity finishes startup and the CLI can connect to the project - Successful launch, restart, existing-process, and quit paths return JSON with: diff --git a/cli/internal/cli/launch.go b/cli/internal/cli/launch.go index 9f25193af..11efd397d 100644 --- a/cli/internal/cli/launch.go +++ b/cli/internal/cli/launch.go @@ -33,6 +33,7 @@ var ( resolveUnityExecutablePathForLaunch = resolveUnityExecutablePath waitForUnityLockfileForLaunch = waitForUnityLockfile waitForToolReadinessForLaunch = waitForToolReadiness + probeProjectIpcForLaunchFallback = probeToolReadinessSequence ) var editorVersionPattern = regexp.MustCompile(`(?m)^m_EditorVersion:\s*(.+)$`) @@ -197,6 +198,12 @@ func runLaunch(ctx context.Context, options launchOptions, startPath string, std runningProcess, err := findRunningUnityProcessForLaunch(ctx, projectRoot) if err != nil { + // Sandboxes can block the process scan (e.g. /bin/ps). A responding project IPC + // proves Unity is running, so plain launch must not fail on the scan alone. + // Restart and quit still fail because they need a process id to kill. + if !options.restart && !options.quit && probeProjectIpcForLaunchFallback(ctx, projectRoot) == nil { + return writeDetectionFallbackLaunchReadyResponse(stdout, stderr, projectRoot, err) + } writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: launchCommandName}) return 1 } diff --git a/cli/internal/cli/launch_ready.go b/cli/internal/cli/launch_ready.go index fdd935b53..cb1ecc0d8 100644 --- a/cli/internal/cli/launch_ready.go +++ b/cli/internal/cli/launch_ready.go @@ -2,6 +2,7 @@ package cli import ( "encoding/json" + "fmt" "io" ) @@ -35,6 +36,22 @@ func writeLaunchReadinessWait(stdout io.Writer, spinner *terminalSpinner) { } } +// writeDetectionFallbackLaunchReadyResponse reports a running Editor that was proven via +// the project IPC after the process scan failed, so no process id or window focus is available. +func writeDetectionFallbackLaunchReadyResponse(stdout io.Writer, stderr io.Writer, projectRoot string, detectionErr error) int { + return writeLaunchResponse(stdout, stderr, launchReadyResponse{ + Success: true, + Ready: true, + ServerReady: true, + ProjectIpcReady: true, + AlreadyRunning: true, + ProjectRoot: projectRoot, + Message: fmt.Sprintf( + "Unity is already running and ready. The process scan failed (%v), so the existing window was not focused.", + detectionErr), + }) +} + func writeExistingLaunchReadyResponse(stdout io.Writer, stderr io.Writer, projectRoot string, currentPid int) int { return writeLaunchResponse(stdout, stderr, launchReadyResponse{ Success: true, diff --git a/cli/internal/cli/launch_test.go b/cli/internal/cli/launch_test.go index b40329da5..ea175c395 100644 --- a/cli/internal/cli/launch_test.go +++ b/cli/internal/cli/launch_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -496,3 +497,96 @@ func decodeLaunchResponseFromOutput(t *testing.T, output string) launchReadyResp } return response } + +// Verifies launch survives a blocked process scan (e.g. sandboxed /bin/ps) by +// probing the project IPC and reporting the running Editor instead of failing. +func TestRunLaunchFallsBackToIpcProbeWhenProcessScanFails(t *testing.T) { + originalFinder := findRunningUnityProcessForLaunch + originalProbe := probeProjectIpcForLaunchFallback + findRunningUnityProcessForLaunch = func(context.Context, string) (*unityProcess, error) { + return nil, errors.New("failed to retrieve Unity process list: /bin/ps: operation not permitted") + } + probeProjectIpcForLaunchFallback = func(context.Context, string) error { + return nil + } + t.Cleanup(func() { + findRunningUnityProcessForLaunch = originalFinder + probeProjectIpcForLaunchFallback = originalProbe + }) + + projectRoot := createLaunchTestProject(t) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := runLaunch(context.Background(), launchOptions{projectPath: projectRoot}, projectRoot, &stdout, &stderr) + + if code != 0 { + t.Fatalf("exit code mismatch: %d stderr=%s", code, stderr.String()) + } + response := decodeLaunchResponseFromOutput(t, stdout.String()) + if !response.Success || !response.Ready || !response.AlreadyRunning { + t.Fatalf("fallback response mismatch: %+v", response) + } + if response.CurrentProcessId != nil { + t.Fatalf("fallback cannot know the process id: %+v", response) + } + if !strings.Contains(response.Message, "window was not focused") { + t.Fatalf("message should explain the skipped focus: %+v", response) + } +} + +// Verifies launch still fails when the process scan is blocked and the project IPC is silent. +func TestRunLaunchReportsScanErrorWhenIpcProbeAlsoFails(t *testing.T) { + originalFinder := findRunningUnityProcessForLaunch + originalProbe := probeProjectIpcForLaunchFallback + findRunningUnityProcessForLaunch = func(context.Context, string) (*unityProcess, error) { + return nil, errors.New("failed to retrieve Unity process list: /bin/ps: operation not permitted") + } + probeProjectIpcForLaunchFallback = func(context.Context, string) error { + return errors.New("connection refused") + } + t.Cleanup(func() { + findRunningUnityProcessForLaunch = originalFinder + probeProjectIpcForLaunchFallback = originalProbe + }) + + projectRoot := createLaunchTestProject(t) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := runLaunch(context.Background(), launchOptions{projectPath: projectRoot}, projectRoot, &stdout, &stderr) + + if code != 1 { + t.Fatalf("expected failure, got %d stdout=%s", code, stdout.String()) + } + if !strings.Contains(stderr.String(), "operation not permitted") { + t.Fatalf("stderr should carry the scan error: %s", stderr.String()) + } +} + +// Verifies restart and quit refuse the fallback because they must kill a known process id. +func TestRunLaunchRestartDoesNotUseIpcProbeFallback(t *testing.T) { + originalFinder := findRunningUnityProcessForLaunch + originalProbe := probeProjectIpcForLaunchFallback + findRunningUnityProcessForLaunch = func(context.Context, string) (*unityProcess, error) { + return nil, errors.New("failed to retrieve Unity process list: /bin/ps: operation not permitted") + } + probeProjectIpcForLaunchFallback = func(context.Context, string) error { + t.Fatal("restart must not consult the IPC probe fallback") + return nil + } + t.Cleanup(func() { + findRunningUnityProcessForLaunch = originalFinder + probeProjectIpcForLaunchFallback = originalProbe + }) + + projectRoot := createLaunchTestProject(t) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := runLaunch(context.Background(), launchOptions{restart: true, projectPath: projectRoot}, projectRoot, &stdout, &stderr) + + if code != 1 { + t.Fatalf("expected failure, got %d stdout=%s", code, stdout.String()) + } +} From 7bc7ea8a6a4618232c89e6356dea68837523509c Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 11 Jun 2026 14:28:21 +0900 Subject: [PATCH 11/31] Add --code-file input to execute-dynamic-code Supervised AI agents reported that shell-quoting long multi-line C# snippets for --code is error-prone. --code-file reads the source from a file CLI-side and forwards it as the Code parameter, so no Unity schema change is needed. Combining it with --code is rejected to avoid one silently shadowing the other. --- .../ExecuteDynamicCode/Skill/SKILL.md | 5 +- cli/internal/cli/dynamic_code_file.go | 70 ++++++++++++ cli/internal/cli/dynamic_code_file_test.go | 106 ++++++++++++++++++ cli/internal/cli/run.go | 16 +++ 4 files changed, 195 insertions(+), 2 deletions(-) create mode 100644 cli/internal/cli/dynamic_code_file.go create mode 100644 cli/internal/cli/dynamic_code_file_test.go diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/SKILL.md index 225443be9..ee44f5d45 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/SKILL.md @@ -23,8 +23,9 @@ This tool can inspect reachable Unity state, such as GameObjects, components, pu ## Parameters -- `--code ''` (required): Inline C# statements to execute. Use direct statements only; `return` is optional, and `using` directives may appear at the top of the snippet. -- **Shell quoting**: bash/zsh uses single quotes, for example `uloop execute-dynamic-code --code 'using UnityEngine; return Mathf.PI;'`. PowerShell doubles inner quotes (`'Debug.Log(""Hello!"");'`). +- `--code ''`: Inline C# statements to execute. Use direct statements only; `return` is optional, and `using` directives may appear at the top of the snippet. +- `--code-file `: Read the C# statements from a file instead of `--code`. Prefer this for long or multi-line snippets because it removes shell quoting entirely. Exactly one of `--code` or `--code-file` is required; combining them is an error. +- **Shell quoting**: bash/zsh uses single quotes, for example `uloop execute-dynamic-code --code 'using UnityEngine; return Mathf.PI;'`. PowerShell doubles inner quotes (`'Debug.Log(""Hello!"");'`). With `--code-file` no quoting is needed. - `--parameters {}` (advanced, optional): Pass an object when reusing a snippet with varying data or when keeping values outside the code. Values are exposed as `parameters["param0"]`, `parameters["param1"]`, and so on. Omit this flag for most snippets, and pass an object instead of a JSON string. - `--no-wait-for-domain-reload` (optional): Return without waiting for Domain Reload recovery. Omit this for normal editor mutation workflows. diff --git a/cli/internal/cli/dynamic_code_file.go b/cli/internal/cli/dynamic_code_file.go new file mode 100644 index 000000000..0e3f9b7f0 --- /dev/null +++ b/cli/internal/cli/dynamic_code_file.go @@ -0,0 +1,70 @@ +package cli + +import ( + "fmt" + "os" + "strings" +) + +const ( + dynamicCodeFileFlagName = "code-file" + dynamicCodeCodePropertyName = "Code" +) + +// extractDynamicCodeFileFlag pulls --code-file out of execute-dynamic-code args before +// generic tool parsing, because the flag is CLI-side sugar and not part of the Unity schema. +func extractDynamicCodeFileFlag(command string, args []string) ([]string, string, error) { + if command != executeDynamicCodeCommandName { + return args, "", nil + } + + remaining := make([]string, 0, len(args)) + path := "" + for index := 0; index < len(args); index++ { + arg := args[index] + if arg != "--"+dynamicCodeFileFlagName && !strings.HasPrefix(arg, "--"+dynamicCodeFileFlagName+"=") { + remaining = append(remaining, arg) + continue + } + + name, value, consumedNext, err := parseFlagValue(arg, args, index) + if err != nil { + return nil, "", err + } + if name != dynamicCodeFileFlagName { + remaining = append(remaining, arg) + continue + } + path = value + if consumedNext { + index++ + } + } + + return remaining, path, nil +} + +// applyDynamicCodeFileParam loads the snippet file into the Code parameter, so long C# +// sources avoid shell quoting entirely. +func applyDynamicCodeFileParam(params map[string]any, path string) error { + if path == "" { + return nil + } + + if _, exists := params[dynamicCodeCodePropertyName]; exists { + return &argumentError{ + message: "--code and --code-file cannot be combined", + option: "--" + dynamicCodeFileFlagName, + command: executeDynamicCodeCommandName, + nextActions: []string{"Pass the C# source either inline with `--code` or from a file with `--code-file `."}, + } + } + + content, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read --%s %s: %w", dynamicCodeFileFlagName, path, err) + } + + params[dynamicCodeCodePropertyName] = string(content) + return nil +} diff --git a/cli/internal/cli/dynamic_code_file_test.go b/cli/internal/cli/dynamic_code_file_test.go new file mode 100644 index 000000000..47f5cf228 --- /dev/null +++ b/cli/internal/cli/dynamic_code_file_test.go @@ -0,0 +1,106 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// Verifies --code-file is extracted from execute-dynamic-code args in both flag forms. +func TestExtractDynamicCodeFileFlagParsesBothForms(t *testing.T) { + remaining, path, err := extractDynamicCodeFileFlag( + executeDynamicCodeCommandName, + []string{"--code-file", "/tmp/snippet.cs", "--timeout-seconds", "30"}, + ) + if err != nil { + t.Fatalf("extract failed: %v", err) + } + if path != "/tmp/snippet.cs" { + t.Fatalf("path mismatch: %s", path) + } + if len(remaining) != 2 || remaining[0] != "--timeout-seconds" { + t.Fatalf("remaining args mismatch: %#v", remaining) + } + + remaining, path, err = extractDynamicCodeFileFlag( + executeDynamicCodeCommandName, + []string{"--code-file=/tmp/snippet.cs"}, + ) + if err != nil { + t.Fatalf("equals-form extract failed: %v", err) + } + if path != "/tmp/snippet.cs" || len(remaining) != 0 { + t.Fatalf("equals-form mismatch: %s %#v", path, remaining) + } +} + +// Verifies the flag is reserved for execute-dynamic-code and ignored for other tools. +func TestExtractDynamicCodeFileFlagIgnoresOtherCommands(t *testing.T) { + args := []string{"--code-file", "/tmp/snippet.cs"} + remaining, path, err := extractDynamicCodeFileFlag("get-logs", args) + if err != nil { + t.Fatalf("extract failed: %v", err) + } + if path != "" || len(remaining) != 2 { + t.Fatalf("other commands must pass args through: %s %#v", path, remaining) + } +} + +// Verifies the file content lands in the Code parameter, so long C# avoids shell quoting. +func TestApplyDynamicCodeFileParamReadsFile(t *testing.T) { + codePath := filepath.Join(t.TempDir(), "snippet.cs") + source := "using UnityEngine;\nreturn \"ok\";\n" + if err := os.WriteFile(codePath, []byte(source), 0o644); err != nil { + t.Fatalf("failed to write snippet: %v", err) + } + + params := map[string]any{} + if err := applyDynamicCodeFileParam(params, codePath); err != nil { + t.Fatalf("apply failed: %v", err) + } + if params["Code"] != source { + t.Fatalf("Code param mismatch: %#v", params["Code"]) + } +} + +// Verifies --code and --code-file cannot silently shadow each other. +func TestApplyDynamicCodeFileParamRejectsConflictingCode(t *testing.T) { + codePath := filepath.Join(t.TempDir(), "snippet.cs") + if err := os.WriteFile(codePath, []byte("return 1;"), 0o644); err != nil { + t.Fatalf("failed to write snippet: %v", err) + } + + params := map[string]any{"Code": "return 2;"} + err := applyDynamicCodeFileParam(params, codePath) + if err == nil { + t.Fatal("expected conflict error") + } + if !strings.Contains(err.Error(), "--code-file") { + t.Fatalf("error should name the conflicting option: %v", err) + } +} + +// Verifies an unreadable file fails fast with the path in the message. +func TestApplyDynamicCodeFileParamReportsUnreadableFile(t *testing.T) { + missingPath := filepath.Join(t.TempDir(), "missing.cs") + + err := applyDynamicCodeFileParam(map[string]any{}, missingPath) + if err == nil { + t.Fatal("expected unreadable file error") + } + if !strings.Contains(err.Error(), missingPath) { + t.Fatalf("error should include the path: %v", err) + } +} + +// Verifies no path means no change, keeping plain --code calls untouched. +func TestApplyDynamicCodeFileParamIsNoOpWithoutPath(t *testing.T) { + params := map[string]any{"Code": "return 3;"} + if err := applyDynamicCodeFileParam(params, ""); err != nil { + t.Fatalf("no-op apply failed: %v", err) + } + if params["Code"] != "return 3;" { + t.Fatalf("params must stay untouched: %#v", params) + } +} diff --git a/cli/internal/cli/run.go b/cli/internal/cli/run.go index 92bafa454..96ad4f67a 100644 --- a/cli/internal/cli/run.go +++ b/cli/internal/cli/run.go @@ -109,6 +109,15 @@ func RunProjectLocal(ctx context.Context, args []string, stdout io.Writer, stder return 1 } + commandArgs, dynamicCodeFilePath, err := extractDynamicCodeFileFlag(command, commandArgs) + if err != nil { + writeClassifiedError(stderr, err, errorContext{ + projectRoot: connection.ProjectRoot, + command: command, + }) + return 1 + } + params, nestedProjectPath, err := buildToolParams(commandArgs, tool) if err != nil { writeClassifiedError(stderr, err, errorContext{ @@ -117,6 +126,13 @@ func RunProjectLocal(ctx context.Context, args []string, stdout io.Writer, stder }) return 1 } + if err := applyDynamicCodeFileParam(params, dynamicCodeFilePath); err != nil { + writeClassifiedError(stderr, err, errorContext{ + projectRoot: connection.ProjectRoot, + command: command, + }) + return 1 + } if nestedProjectPath != "" && nestedProjectPath != connection.ProjectRoot { writeErrorEnvelope(stderr, (&argumentError{ message: "--project-path must target the same Unity project for this command", From 6929738c7f6ceb7b2d23813c4f5e7008b6ae13c3 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 11 Jun 2026 14:28:21 +0900 Subject: [PATCH 12/31] Retry server-busy responses inside the bounded connection retry window Agents running back-to-back uloop commands hit UNITY_SERVER_BUSY and had to hand-roll retries even though the error is marked SafeToRetry: a busy response means Unity never executed the request. Reuse the existing 10s retry window to poll until the running tool frees the slot, and surface the original busy envelope unchanged if it never does. --- cli/internal/cli/connection_retry.go | 28 +++++ cli/internal/cli/connection_retry_test.go | 122 ++++++++++++++++++++++ 2 files changed, 150 insertions(+) diff --git a/cli/internal/cli/connection_retry.go b/cli/internal/cli/connection_retry.go index 5040098ce..fffcfd08e 100644 --- a/cli/internal/cli/connection_retry.go +++ b/cli/internal/cli/connection_retry.go @@ -2,6 +2,7 @@ package cli import ( "context" + "encoding/json" "errors" "fmt" "time" @@ -90,6 +91,21 @@ func sendWithTransientConnectionRetryAndResponseTimeout( client = client.WithResponseTimeout(responseTimeout) } outcome, err := client.SendWithProgressOutcomeAcceptContext(ctx, retryContext, method, params, progress) + if isUnityServerBusyRPCError(err) { + // Busy means the request was never executed, so a bounded retry is safe and + // usually absorbs back-to-back tool calls without bothering the caller. + lastOutcome = outcome + lastErr = err + select { + case <-retryContext.Done(): + if ctx.Err() != nil { + return lastOutcome, ctx.Err() + } + return lastOutcome, lastErr + case <-time.After(serverConnectionRetryPoll): + } + continue + } if !shouldRetryUndispatchedConnection(err, outcome) { return outcome, err } @@ -141,6 +157,18 @@ func sendWithTransientConnectionRetryAndResponseTimeout( } } +func isUnityServerBusyRPCError(err error) bool { + var rpcErr *unityipc.RPCError + if !errors.As(err, &rpcErr) { + return false + } + decodedData := map[string]any{} + if len(rpcErr.Data) > 0 { + _ = json.Unmarshal(rpcErr.Data, &decodedData) + } + return rpcDataType(decodedData) == "server_busy" +} + func shouldRetryUndispatchedConnection(err error, outcome unityipc.UnitySendOutcome) bool { if err == nil || outcome.RequestDispatched { return false diff --git a/cli/internal/cli/connection_retry_test.go b/cli/internal/cli/connection_retry_test.go index d6607e435..ac06209ed 100644 --- a/cli/internal/cli/connection_retry_test.go +++ b/cli/internal/cli/connection_retry_test.go @@ -379,3 +379,125 @@ func TestSendWithTransientConnectionRetryKeepsRetryTimeoutBeforeAcceptedAck(t *t default: } } + +// Verifies a server_busy response is retried, because the request was never executed +// and Unity frees the execution slot when the running tool completes. +func TestSendWithTransientConnectionRetryRetriesBusyResponses(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("TCP endpoint injection is only used by this non-Windows client test") + } + + originalPoll := serverConnectionRetryPoll + serverConnectionRetryPoll = 5 * time.Millisecond + t.Cleanup(func() { + serverConnectionRetryPoll = originalPoll + }) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + defer func() { + _ = listener.Close() + }() + + busy := `{"jsonrpc":"2.0","id":1,"error":{"code":-32603,"message":"Unity is busy running 'compile'.","data":{"type":"server_busy","runningToolName":"compile","requestedToolName":"get-logs","message":"busy"}}}` + ok := `{"jsonrpc":"2.0","result":{"ok":true},"id":1}` + go func() { + for _, payload := range []string{busy, ok} { + conn, err := listener.Accept() + if err != nil { + return + } + if _, err := unityipc.Read(bufio.NewReader(conn)); err != nil { + _ = conn.Close() + return + } + _ = unityipc.Write(conn, []byte(payload)) + _ = conn.Close() + } + }() + + connection := unityipc.Connection{ + Endpoint: unityipc.Endpoint{ + Network: "tcp", + Address: listener.Addr().String(), + }, + ProjectRoot: t.TempDir(), + } + + outcome, err := sendWithTransientConnectionRetry( + context.Background(), + connection, + "get-logs", + map[string]any{}, + nil) + if err != nil { + t.Fatalf("busy response should be retried to success: %v", err) + } + if string(outcome.Result) != `{"ok":true}` { + t.Fatalf("final result mismatch: %s", outcome.Result) + } +} + +// Verifies a persistently busy Unity still surfaces the busy error after the retry window. +func TestSendWithTransientConnectionRetryReturnsBusyAfterRetryWindow(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("TCP endpoint injection is only used by this non-Windows client test") + } + + originalTimeout := serverConnectionRetryTimeout + originalPoll := serverConnectionRetryPoll + serverConnectionRetryTimeout = 30 * time.Millisecond + serverConnectionRetryPoll = 5 * time.Millisecond + t.Cleanup(func() { + serverConnectionRetryTimeout = originalTimeout + serverConnectionRetryPoll = originalPoll + }) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + defer func() { + _ = listener.Close() + }() + + busy := `{"jsonrpc":"2.0","id":1,"error":{"code":-32603,"message":"Unity is busy running 'compile'.","data":{"type":"server_busy","runningToolName":"compile","requestedToolName":"get-logs","message":"busy"}}}` + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + if _, err := unityipc.Read(bufio.NewReader(conn)); err != nil { + _ = conn.Close() + return + } + _ = unityipc.Write(conn, []byte(busy)) + _ = conn.Close() + } + }() + + connection := unityipc.Connection{ + Endpoint: unityipc.Endpoint{ + Network: "tcp", + Address: listener.Addr().String(), + }, + ProjectRoot: t.TempDir(), + } + + _, err = sendWithTransientConnectionRetry( + context.Background(), + connection, + "get-logs", + map[string]any{}, + nil) + if err == nil { + t.Fatal("expected busy error after retry window") + } + var rpcErr *unityipc.RPCError + if !errors.As(err, &rpcErr) { + t.Fatalf("busy must surface as the original RPC error, got: %v", err) + } +} From 985ac72afe3580fd44ed97d9b1716b5443a20a83 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 11 Jun 2026 14:39:26 +0900 Subject: [PATCH 13/31] Report press-edge observability from simulate-keyboard Agents saw Press/KeyDown return Success while gameplay never observed wasPressedThisFrame, and had to rewrite game code to hand-rolled edge detection to cope. The response now carries PressEdgeObserved: a monitor on InputSystem.onAfterUpdate checks the edge inside gameplay input updates (editor updates excluded, since presses consumed there are invisible to gameplay polling). Editor-tick polling was tried first and reproduced the exact miss, which confirms the monitor placement. --- .../Tests/PlayMode/SimulateKeyboardTests.cs | 47 +++++++++++++++++++ .../UnityCliLoopInputSimulationTypes.cs | 1 + .../SimulateKeyboardResponse.cs | 1 + .../SimulateKeyboard/SimulateKeyboardTool.cs | 1 + .../SimulateKeyboardUseCase.cs | 44 +++++++++++++++-- .../SimulateKeyboard/Skill/SKILL.md | 1 + 6 files changed, 91 insertions(+), 4 deletions(-) diff --git a/Assets/Tests/PlayMode/SimulateKeyboardTests.cs b/Assets/Tests/PlayMode/SimulateKeyboardTests.cs index 71f882b2d..78144c8c9 100644 --- a/Assets/Tests/PlayMode/SimulateKeyboardTests.cs +++ b/Assets/Tests/PlayMode/SimulateKeyboardTests.cs @@ -101,6 +101,53 @@ public IEnumerator Press_Should_InjectKeyDownAndUp() Assert.IsFalse(keyboard[Key.W].isPressed, "Key should be released after press"); } + [UnityTest] + public IEnumerator Press_Should_ReportObservedPressEdge() + { + // Verifies the response tells callers whether wasPressedThisFrame was actually + // observable, so agents can distinguish a delivered edge from a missed one. + yield return null; + + yield return RunTool(new JObject + { + ["action"] = KeyboardAction.Press.ToString(), + ["key"] = "Space" + }); + + Assert.IsTrue(lastResponse.Success); + Assert.IsTrue( + lastResponse.PressEdgeObserved.HasValue, + "Press must report press-edge observability"); + Assert.IsTrue( + lastResponse.PressEdgeObserved!.Value, + "A successful Press in PlayMode should observe the press edge"); + } + + [UnityTest] + public IEnumerator KeyDown_Should_ReportObservedPressEdge() + { + // Verifies KeyDown also reports edge observability, because agents fall back to it + // when Press appears to be missed by gameplay polling. + yield return null; + + yield return RunTool(new JObject + { + ["action"] = KeyboardAction.KeyDown.ToString(), + ["key"] = "Space" + }); + + Assert.IsTrue(lastResponse.Success); + Assert.IsTrue( + lastResponse.PressEdgeObserved.HasValue && lastResponse.PressEdgeObserved.Value, + "A successful KeyDown in PlayMode should observe the press edge"); + + yield return RunTool(new JObject + { + ["action"] = KeyboardAction.KeyUp.ToString(), + ["key"] = "Space" + }); + } + [UnityTest] public IEnumerator Press_WithDuration_Should_HoldKey() { diff --git a/Packages/src/Editor/FirstPartyTools/Common/InputSimulation/UnityCliLoopInputSimulationTypes.cs b/Packages/src/Editor/FirstPartyTools/Common/InputSimulation/UnityCliLoopInputSimulationTypes.cs index 831c675ac..29d6d4dc6 100644 --- a/Packages/src/Editor/FirstPartyTools/Common/InputSimulation/UnityCliLoopInputSimulationTypes.cs +++ b/Packages/src/Editor/FirstPartyTools/Common/InputSimulation/UnityCliLoopInputSimulationTypes.cs @@ -98,6 +98,7 @@ public sealed class UnityCliLoopKeyboardSimulationResult public bool InterruptedByPausePoint { get; set; } public string? PausePointId { get; set; } public int? PausePointHitCount { get; set; } + public bool? PressEdgeObserved { get; set; } } /// diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs index 759ac22b6..719ae8472 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs @@ -16,6 +16,7 @@ public class SimulateKeyboardResponse : UnityCliLoopToolResponse public bool InterruptedByPausePoint { get; set; } public string? PausePointId { get; set; } public int? PausePointHitCount { get; set; } + public bool? PressEdgeObserved { get; set; } public SimulateKeyboardResponse() { diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardTool.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardTool.cs index 8ebbcbf7a..b4a386816 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardTool.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardTool.cs @@ -51,6 +51,7 @@ private static SimulateKeyboardResponse ToResponse(UnityCliLoopKeyboardSimulatio InterruptedByPausePoint = result.InterruptedByPausePoint, PausePointId = result.PausePointId, PausePointHitCount = result.PausePointHitCount, + PressEdgeObserved = result.PressEdgeObserved, }; } } diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs index bcab96b80..10bc5fc79 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs @@ -171,8 +171,16 @@ private async Task ExecutePress( SimulateKeyboardOverlayState.ShowPress(keyName); KeyboardKeyState.RegisterTransientKey(key); bool pressWasApplied = false; + bool pressEdgeObserved = false; InputSimulationWaitOutcome waitOutcome = InputSimulationWaitOutcome.Completed; + // The edge must be probed inside gameplay input updates: editor-tick polling can + // miss the single frame where wasPressedThisFrame is true, and an editor-update + // consumed press is exactly the failure gameplay code cannot see. + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); + Action pressEdgeMonitor = () => pressEdgeObserved |= IsGameplayPressEdgeVisible(keyboard, key); + InputSystem.onAfterUpdate += pressEdgeMonitor; + try { waitOutcome = await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( @@ -187,6 +195,8 @@ private async Task ExecutePress( } finally { + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); + InputSystem.onAfterUpdate -= pressEdgeMonitor; if (waitOutcome == InputSimulationWaitOutcome.TimedOut) { ScheduleTimedOutPressCleanup(keyboard, key, pressWasApplied); @@ -229,12 +239,16 @@ private async Task ExecutePress( } string durationText = duration > 0f ? $" for {InputSimulationDurationFormatter.FormatSeconds(duration)}s" : ""; + string edgeText = pressEdgeObserved + ? "" + : " (press edge was not observed via wasPressedThisFrame; gameplay polling may have missed it, so retry or verify with a focused log)"; return new UnityCliLoopKeyboardSimulationResult { Success = true, - Message = $"Pressed '{keyName}'{durationText}", + Message = $"Pressed '{keyName}'{durationText}{edgeText}", Action = UnityCliLoopKeyboardAction.Press.ToString(), - KeyName = keyName + KeyName = keyName, + PressEdgeObserved = pressEdgeObserved }; } @@ -255,8 +269,13 @@ private async Task ExecuteKeyDown(Keyboard bool keyDownApplied = false; bool committed = false; + bool pressEdgeObserved = false; InputSimulationWaitOutcome waitOutcome = InputSimulationWaitOutcome.Completed; + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); + Action keyDownEdgeMonitor = () => pressEdgeObserved |= IsGameplayPressEdgeVisible(keyboard, key); + InputSystem.onAfterUpdate += keyDownEdgeMonitor; + try { waitOutcome = await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( @@ -275,6 +294,8 @@ private async Task ExecuteKeyDown(Keyboard } finally { + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); + InputSystem.onAfterUpdate -= keyDownEdgeMonitor; if (waitOutcome == InputSimulationWaitOutcome.TimedOut) { ScheduleTimedOutHeldKeyCleanup(keyboard, key, keyName, keyDownApplied); @@ -300,12 +321,16 @@ private async Task ExecuteKeyDown(Keyboard return TimedOutKeyResult(UnityCliLoopKeyboardAction.KeyDown, keyName); } + string keyDownEdgeText = pressEdgeObserved + ? "" + : " (press edge was not observed via wasPressedThisFrame; gameplay polling may have missed it)"; return new UnityCliLoopKeyboardSimulationResult { Success = true, - Message = $"Key '{keyName}' held down", + Message = $"Key '{keyName}' held down{keyDownEdgeText}", Action = UnityCliLoopKeyboardAction.KeyDown.ToString(), - KeyName = keyName + KeyName = keyName, + PressEdgeObserved = pressEdgeObserved }; } @@ -558,6 +583,17 @@ private static bool CanInjectKeyboardState(Keyboard keyboard) { return EditorApplication.isPlaying && keyboard != null; } + + // Runs inside InputSystem.onAfterUpdate. Editor updates are excluded because a press + // consumed there never surfaces as wasPressedThisFrame to gameplay Update polling. + private static bool IsGameplayPressEdgeVisible(Keyboard keyboard, Key key) + { + if (UnityEngine.InputSystem.LowLevel.InputState.currentUpdateType == UnityEngine.InputSystem.LowLevel.InputUpdateType.Editor) + { + return false; + } + return keyboard[key].wasPressedThisFrame; + } #endif } } diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md index b38e239b8..aa11d4c8a 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md @@ -97,6 +97,7 @@ Returns JSON with: - `InterruptedByPausePoint` (boolean): True when Unity paused during Pause Point inspection and the input bookkeeping was safely released - `PausePointId` (string, nullable): The marker id when a `UloopPausePoint.Pause` marker caused the interruption - `PausePointHitCount` (integer, nullable): The marker hit count when a `UloopPausePoint.Pause` marker caused the interruption +- `PressEdgeObserved` (boolean, nullable): For `Press` and `KeyDown`, whether the press edge (`wasPressedThisFrame`) was actually visible inside a gameplay input update. `false` means the CLI succeeded but gameplay polling most likely missed the edge (e.g. the press was consumed by an editor-only input update) — retry the input or verify with a focused log instead of trusting `Success` alone. `null` for `KeyUp` ## Prerequisites From b535e1356e327f7708b71978ea7395a8b8eaef47 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 11 Jun 2026 14:41:33 +0900 Subject: [PATCH 14/31] Regenerate skill copies for the launch, dynamic-code, and keyboard updates Generated copies under .claude/ and .agents/ follow the source skill edits: launch IPC-probe fallback, execute-dynamic-code --code-file, PressEdgeObserved, and the MatchingLogs naming note. --- .agents/skills/uloop-execute-dynamic-code/SKILL.md | 5 +++-- .agents/skills/uloop-launch/SKILL.md | 1 + .agents/skills/uloop-simulate-keyboard/SKILL.md | 1 + .agents/skills/uloop-wait-for-pause-point/SKILL.md | 4 ++-- .claude/skills/uloop-execute-dynamic-code/SKILL.md | 5 +++-- .claude/skills/uloop-launch/SKILL.md | 1 + .claude/skills/uloop-simulate-keyboard/SKILL.md | 1 + .claude/skills/uloop-wait-for-pause-point/SKILL.md | 4 ++-- 8 files changed, 14 insertions(+), 8 deletions(-) diff --git a/.agents/skills/uloop-execute-dynamic-code/SKILL.md b/.agents/skills/uloop-execute-dynamic-code/SKILL.md index 225443be9..ee44f5d45 100644 --- a/.agents/skills/uloop-execute-dynamic-code/SKILL.md +++ b/.agents/skills/uloop-execute-dynamic-code/SKILL.md @@ -23,8 +23,9 @@ This tool can inspect reachable Unity state, such as GameObjects, components, pu ## Parameters -- `--code ''` (required): Inline C# statements to execute. Use direct statements only; `return` is optional, and `using` directives may appear at the top of the snippet. -- **Shell quoting**: bash/zsh uses single quotes, for example `uloop execute-dynamic-code --code 'using UnityEngine; return Mathf.PI;'`. PowerShell doubles inner quotes (`'Debug.Log(""Hello!"");'`). +- `--code ''`: Inline C# statements to execute. Use direct statements only; `return` is optional, and `using` directives may appear at the top of the snippet. +- `--code-file `: Read the C# statements from a file instead of `--code`. Prefer this for long or multi-line snippets because it removes shell quoting entirely. Exactly one of `--code` or `--code-file` is required; combining them is an error. +- **Shell quoting**: bash/zsh uses single quotes, for example `uloop execute-dynamic-code --code 'using UnityEngine; return Mathf.PI;'`. PowerShell doubles inner quotes (`'Debug.Log(""Hello!"");'`). With `--code-file` no quoting is needed. - `--parameters {}` (advanced, optional): Pass an object when reusing a snippet with varying data or when keeping values outside the code. Values are exposed as `parameters["param0"]`, `parameters["param1"]`, and so on. Omit this flag for most snippets, and pass an object instead of a JSON string. - `--no-wait-for-domain-reload` (optional): Return without waiting for Domain Reload recovery. Omit this for normal editor mutation workflows. diff --git a/.agents/skills/uloop-launch/SKILL.md b/.agents/skills/uloop-launch/SKILL.md index 3530881aa..7d6b06e1f 100644 --- a/.agents/skills/uloop-launch/SKILL.md +++ b/.agents/skills/uloop-launch/SKILL.md @@ -50,6 +50,7 @@ uloop launch --quit - Prints detected Unity version - Prints project path - If Unity is already running, focuses the existing window and verifies tool readiness +- If the process scan is blocked by the environment (e.g. sandboxed `ps`), plain launch falls back to probing the project IPC; when Unity responds it reports `AlreadyRunning: true` without focusing the window instead of failing. `--restart` and `--quit` still fail because they need the process id - If launching or restarting, prints when it is waiting for Unity CLI Loop server readiness - If launching or restarting, waits until Unity finishes startup and the CLI can connect to the project - Successful launch, restart, existing-process, and quit paths return JSON with: diff --git a/.agents/skills/uloop-simulate-keyboard/SKILL.md b/.agents/skills/uloop-simulate-keyboard/SKILL.md index b38e239b8..aa11d4c8a 100644 --- a/.agents/skills/uloop-simulate-keyboard/SKILL.md +++ b/.agents/skills/uloop-simulate-keyboard/SKILL.md @@ -97,6 +97,7 @@ Returns JSON with: - `InterruptedByPausePoint` (boolean): True when Unity paused during Pause Point inspection and the input bookkeeping was safely released - `PausePointId` (string, nullable): The marker id when a `UloopPausePoint.Pause` marker caused the interruption - `PausePointHitCount` (integer, nullable): The marker hit count when a `UloopPausePoint.Pause` marker caused the interruption +- `PressEdgeObserved` (boolean, nullable): For `Press` and `KeyDown`, whether the press edge (`wasPressedThisFrame`) was actually visible inside a gameplay input update. `false` means the CLI succeeded but gameplay polling most likely missed the edge (e.g. the press was consumed by an editor-only input update) — retry the input or verify with a focused log instead of trusting `Success` alone. `null` for `KeyUp` ## Prerequisites diff --git a/.agents/skills/uloop-wait-for-pause-point/SKILL.md b/.agents/skills/uloop-wait-for-pause-point/SKILL.md index e5d825418..24cac1f99 100644 --- a/.agents/skills/uloop-wait-for-pause-point/SKILL.md +++ b/.agents/skills/uloop-wait-for-pause-point/SKILL.md @@ -30,7 +30,7 @@ uloop enable-pause-point --id state-transition-applied --timeout-seconds 30 uloop wait-for-pause-point --id state-transition-applied --timeout-seconds 30 ``` -The hit response always embeds the log entries matching the marker id as `MatchingLogs` (`--matching-logs-max-count` adjusts the limit, default 10), so a separate `get-logs` call while paused is unnecessary. An empty `MatchingLogs` array means the fetch succeeded and no matching log exists; if the field is absent, the log fetch itself failed, so fall back to `uloop get-logs --search-text state-transition-applied --max-count 20` while paused. +The hit response always embeds the log entries matching the marker id as `MatchingLogs` (`--matching-logs-max-count` adjusts the limit, default 10), so a separate `get-logs` call while paused is unnecessary. Log embedding is always on; there is no opt-in flag, and a `--include-matching-logs` option no longer exists. An empty `MatchingLogs` array means the fetch succeeded and no matching log exists; if the field is absent, the log fetch itself failed, so fall back to `uloop get-logs --search-text state-transition-applied --max-count 20` while paused. 5. While Unity is still paused, capture any additional evidence with `uloop execute-dynamic-code`, `uloop get-hierarchy`, `uloop find-game-objects`, and one screenshot. 6. Clear the marker with `uloop clear-pause-point --id state-transition-applied` or stop PlayMode before moving on. Use `uloop clear-pause-point --all` to clear every active marker at once, for example when resetting between E2E scenarios. @@ -48,7 +48,7 @@ The hit response always embeds the log entries matching the marker id as `Matchi ## Timeout Checks -If this command times out, the marker line was not reached while the command waited. Read `error.details.hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. A `PAUSE_POINT_EXPIRED` error carries the same hint: it means the marker's own `enable-pause-point --timeout-seconds` window (measured from enable, not from wait) ran out first, so re-enable the marker with a longer timeout. Then inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, runtime conditions not being met, an id mismatch, or Unity already being paused. `error.details.matchingLogs` shows whether the marker's focused log ever appeared. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. +If this command times out, the marker line was not reached while the command waited. Read `error.details.hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. A `PAUSE_POINT_EXPIRED` error carries the same hint: it means the marker's own `enable-pause-point --timeout-seconds` window (measured from enable, not from wait) ran out first, so re-enable the marker with a longer timeout. Then inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, runtime conditions not being met, an id mismatch, or Unity already being paused. `error.details.MatchingLogs` shows whether the marker's focused log ever appeared. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. Use `uloop pause-point-status --id state-transition-applied` only when you need to confirm the marker is armed or inspect the current hit state. Add focused debug logs before the marker when local variables must be captured. diff --git a/.claude/skills/uloop-execute-dynamic-code/SKILL.md b/.claude/skills/uloop-execute-dynamic-code/SKILL.md index 225443be9..ee44f5d45 100644 --- a/.claude/skills/uloop-execute-dynamic-code/SKILL.md +++ b/.claude/skills/uloop-execute-dynamic-code/SKILL.md @@ -23,8 +23,9 @@ This tool can inspect reachable Unity state, such as GameObjects, components, pu ## Parameters -- `--code ''` (required): Inline C# statements to execute. Use direct statements only; `return` is optional, and `using` directives may appear at the top of the snippet. -- **Shell quoting**: bash/zsh uses single quotes, for example `uloop execute-dynamic-code --code 'using UnityEngine; return Mathf.PI;'`. PowerShell doubles inner quotes (`'Debug.Log(""Hello!"");'`). +- `--code ''`: Inline C# statements to execute. Use direct statements only; `return` is optional, and `using` directives may appear at the top of the snippet. +- `--code-file `: Read the C# statements from a file instead of `--code`. Prefer this for long or multi-line snippets because it removes shell quoting entirely. Exactly one of `--code` or `--code-file` is required; combining them is an error. +- **Shell quoting**: bash/zsh uses single quotes, for example `uloop execute-dynamic-code --code 'using UnityEngine; return Mathf.PI;'`. PowerShell doubles inner quotes (`'Debug.Log(""Hello!"");'`). With `--code-file` no quoting is needed. - `--parameters {}` (advanced, optional): Pass an object when reusing a snippet with varying data or when keeping values outside the code. Values are exposed as `parameters["param0"]`, `parameters["param1"]`, and so on. Omit this flag for most snippets, and pass an object instead of a JSON string. - `--no-wait-for-domain-reload` (optional): Return without waiting for Domain Reload recovery. Omit this for normal editor mutation workflows. diff --git a/.claude/skills/uloop-launch/SKILL.md b/.claude/skills/uloop-launch/SKILL.md index 3530881aa..7d6b06e1f 100644 --- a/.claude/skills/uloop-launch/SKILL.md +++ b/.claude/skills/uloop-launch/SKILL.md @@ -50,6 +50,7 @@ uloop launch --quit - Prints detected Unity version - Prints project path - If Unity is already running, focuses the existing window and verifies tool readiness +- If the process scan is blocked by the environment (e.g. sandboxed `ps`), plain launch falls back to probing the project IPC; when Unity responds it reports `AlreadyRunning: true` without focusing the window instead of failing. `--restart` and `--quit` still fail because they need the process id - If launching or restarting, prints when it is waiting for Unity CLI Loop server readiness - If launching or restarting, waits until Unity finishes startup and the CLI can connect to the project - Successful launch, restart, existing-process, and quit paths return JSON with: diff --git a/.claude/skills/uloop-simulate-keyboard/SKILL.md b/.claude/skills/uloop-simulate-keyboard/SKILL.md index b38e239b8..aa11d4c8a 100644 --- a/.claude/skills/uloop-simulate-keyboard/SKILL.md +++ b/.claude/skills/uloop-simulate-keyboard/SKILL.md @@ -97,6 +97,7 @@ Returns JSON with: - `InterruptedByPausePoint` (boolean): True when Unity paused during Pause Point inspection and the input bookkeeping was safely released - `PausePointId` (string, nullable): The marker id when a `UloopPausePoint.Pause` marker caused the interruption - `PausePointHitCount` (integer, nullable): The marker hit count when a `UloopPausePoint.Pause` marker caused the interruption +- `PressEdgeObserved` (boolean, nullable): For `Press` and `KeyDown`, whether the press edge (`wasPressedThisFrame`) was actually visible inside a gameplay input update. `false` means the CLI succeeded but gameplay polling most likely missed the edge (e.g. the press was consumed by an editor-only input update) — retry the input or verify with a focused log instead of trusting `Success` alone. `null` for `KeyUp` ## Prerequisites diff --git a/.claude/skills/uloop-wait-for-pause-point/SKILL.md b/.claude/skills/uloop-wait-for-pause-point/SKILL.md index e5d825418..24cac1f99 100644 --- a/.claude/skills/uloop-wait-for-pause-point/SKILL.md +++ b/.claude/skills/uloop-wait-for-pause-point/SKILL.md @@ -30,7 +30,7 @@ uloop enable-pause-point --id state-transition-applied --timeout-seconds 30 uloop wait-for-pause-point --id state-transition-applied --timeout-seconds 30 ``` -The hit response always embeds the log entries matching the marker id as `MatchingLogs` (`--matching-logs-max-count` adjusts the limit, default 10), so a separate `get-logs` call while paused is unnecessary. An empty `MatchingLogs` array means the fetch succeeded and no matching log exists; if the field is absent, the log fetch itself failed, so fall back to `uloop get-logs --search-text state-transition-applied --max-count 20` while paused. +The hit response always embeds the log entries matching the marker id as `MatchingLogs` (`--matching-logs-max-count` adjusts the limit, default 10), so a separate `get-logs` call while paused is unnecessary. Log embedding is always on; there is no opt-in flag, and a `--include-matching-logs` option no longer exists. An empty `MatchingLogs` array means the fetch succeeded and no matching log exists; if the field is absent, the log fetch itself failed, so fall back to `uloop get-logs --search-text state-transition-applied --max-count 20` while paused. 5. While Unity is still paused, capture any additional evidence with `uloop execute-dynamic-code`, `uloop get-hierarchy`, `uloop find-game-objects`, and one screenshot. 6. Clear the marker with `uloop clear-pause-point --id state-transition-applied` or stop PlayMode before moving on. Use `uloop clear-pause-point --all` to clear every active marker at once, for example when resetting between E2E scenarios. @@ -48,7 +48,7 @@ The hit response always embeds the log entries matching the marker id as `Matchi ## Timeout Checks -If this command times out, the marker line was not reached while the command waited. Read `error.details.hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. A `PAUSE_POINT_EXPIRED` error carries the same hint: it means the marker's own `enable-pause-point --timeout-seconds` window (measured from enable, not from wait) ran out first, so re-enable the marker with a longer timeout. Then inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, runtime conditions not being met, an id mismatch, or Unity already being paused. `error.details.matchingLogs` shows whether the marker's focused log ever appeared. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. +If this command times out, the marker line was not reached while the command waited. Read `error.details.hint` first: it names the most likely cause when PlayMode is not running, Unity is already paused, or the marker was enabled but never hit. A `PAUSE_POINT_EXPIRED` error carries the same hint: it means the marker's own `enable-pause-point --timeout-seconds` window (measured from enable, not from wait) ran out first, so re-enable the marker with a longer timeout. Then inspect `error.details.status`, `hitCount`, `isPlaying`, `isPaused`, `elapsedSinceEnabledMilliseconds`, and `remainingMilliseconds` to distinguish input not being consumed, runtime conditions not being met, an id mismatch, or Unity already being paused. `error.details.MatchingLogs` shows whether the marker's focused log ever appeared. `elapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `wait-for-pause-point`. Use `uloop pause-point-status --id state-transition-applied` only when you need to confirm the marker is armed or inspect the current hit state. Add focused debug logs before the marker when local variables must be captured. From feb8961f21863b48c5ed1801950b793a93b1103c Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 11 Jun 2026 14:41:33 +0900 Subject: [PATCH 15/31] Move launch focus logging into its own file The IPC-probe fallback pushed launch.go past the 500-line architecture limit, so the cohesive focus-logging block moves to launch_focus_log.go. --- cli/internal/cli/launch.go | 50 ------------------------- cli/internal/cli/launch_focus_log.go | 55 ++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 50 deletions(-) create mode 100644 cli/internal/cli/launch_focus_log.go diff --git a/cli/internal/cli/launch.go b/cli/internal/cli/launch.go index 11efd397d..b77e5bc85 100644 --- a/cli/internal/cli/launch.go +++ b/cli/internal/cli/launch.go @@ -436,56 +436,6 @@ func killUnityProcess(pid int) error { return process.Kill() } -func logLaunchExistingFocus(ctx context.Context, projectRoot string, pid int) { - correlationID := newCliVibeCorrelationID() - logLaunchExistingFocusAttempt(projectRoot, pid, correlationID) - if err := focusUnityProcessForLaunch(ctx, pid); err != nil { - logLaunchExistingFocusFailure(projectRoot, pid, err, correlationID) - return - } - logLaunchExistingFocusSuccess(projectRoot, pid, correlationID) -} - -func logLaunchExistingFocusAttempt(projectRoot string, pid int, correlationID string) { - _ = writeCliVibeLog(projectRoot, cliVibeLogEntry{ - Level: "INFO", - Operation: "cli_launch_existing_focus_attempt", - Message: "Attempting to focus the already-running Unity process.", - Context: map[string]any{ - "command": "launch", - "pid": pid, - }, - CorrelationID: correlationID, - }) -} - -func logLaunchExistingFocusSuccess(projectRoot string, pid int, correlationID string) { - _ = writeCliVibeLog(projectRoot, cliVibeLogEntry{ - Level: "INFO", - Operation: "cli_launch_existing_focus_success", - Message: "Focused the already-running Unity process.", - Context: map[string]any{ - "command": "launch", - "pid": pid, - }, - CorrelationID: correlationID, - }) -} - -func logLaunchExistingFocusFailure(projectRoot string, pid int, focusErr error, correlationID string) { - _ = writeCliVibeLog(projectRoot, cliVibeLogEntry{ - Level: "WARNING", - Operation: "cli_launch_existing_focus_failed", - Message: "Failed to focus the already-running Unity process.", - Context: map[string]any{ - "command": "launch", - "pid": pid, - "focusError": errorMessage(focusErr), - }, - CorrelationID: correlationID, - }) -} - func printLaunchHelp(stdout io.Writer) { writeLine(stdout, "Usage:") writeLine(stdout, " uloop launch [options] [project-path]") diff --git a/cli/internal/cli/launch_focus_log.go b/cli/internal/cli/launch_focus_log.go new file mode 100644 index 000000000..8abcef3bc --- /dev/null +++ b/cli/internal/cli/launch_focus_log.go @@ -0,0 +1,55 @@ +package cli + +import ( + "context" +) + +func logLaunchExistingFocus(ctx context.Context, projectRoot string, pid int) { + correlationID := newCliVibeCorrelationID() + logLaunchExistingFocusAttempt(projectRoot, pid, correlationID) + if err := focusUnityProcessForLaunch(ctx, pid); err != nil { + logLaunchExistingFocusFailure(projectRoot, pid, err, correlationID) + return + } + logLaunchExistingFocusSuccess(projectRoot, pid, correlationID) +} + +func logLaunchExistingFocusAttempt(projectRoot string, pid int, correlationID string) { + _ = writeCliVibeLog(projectRoot, cliVibeLogEntry{ + Level: "INFO", + Operation: "cli_launch_existing_focus_attempt", + Message: "Attempting to focus the already-running Unity process.", + Context: map[string]any{ + "command": "launch", + "pid": pid, + }, + CorrelationID: correlationID, + }) +} + +func logLaunchExistingFocusSuccess(projectRoot string, pid int, correlationID string) { + _ = writeCliVibeLog(projectRoot, cliVibeLogEntry{ + Level: "INFO", + Operation: "cli_launch_existing_focus_success", + Message: "Focused the already-running Unity process.", + Context: map[string]any{ + "command": "launch", + "pid": pid, + }, + CorrelationID: correlationID, + }) +} + +func logLaunchExistingFocusFailure(projectRoot string, pid int, focusErr error, correlationID string) { + _ = writeCliVibeLog(projectRoot, cliVibeLogEntry{ + Level: "WARNING", + Operation: "cli_launch_existing_focus_failed", + Message: "Failed to focus the already-running Unity process.", + Context: map[string]any{ + "command": "launch", + "pid": pid, + "focusError": errorMessage(focusErr), + }, + CorrelationID: correlationID, + }) +} From 7dc8409fc78a1852bb53aadba191f03786d1984f Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 11 Jun 2026 15:18:54 +0900 Subject: [PATCH 16/31] Teach the single-flight contract in the busy envelope Supervised agents repeatedly ran uloop commands in parallel without knowing the CLI is single-flight by design, and per-skill documentation would be redundant and easily skipped. The busy message is the one guaranteed touchpoint, so it now states the contract and that the CLI already retried. Also prefer reporting a busy response over a transport error cut short by the expiring retry window, because busy is the truer diagnosis of why the request never ran. --- cli/internal/cli/busy_status.go | 5 ++++- cli/internal/cli/connection_retry.go | 10 ++++++++++ cli/internal/cli/connection_retry_test.go | 16 ++++++++++------ cli/internal/cli/error_envelope_test.go | 4 ++-- 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/cli/internal/cli/busy_status.go b/cli/internal/cli/busy_status.go index 5f26618d2..55fe85db1 100644 --- a/cli/internal/cli/busy_status.go +++ b/cli/internal/cli/busy_status.go @@ -72,9 +72,12 @@ func unityServerBusyMessage(fallback string, data map[string]any, requestedComma if runningToolName == "" || requestedToolName == "" { return fallback } + // This surfaces only after the CLI's bounded busy retry gives up, so it is the one + // guaranteed teaching moment for the single-flight contract. return fmt.Sprintf( - "'%s' was not executed because Unity is busy running '%s'. Retry after the running tool completes.", + "'%s' was not executed because Unity is busy running '%s'. uloop is single-flight by design; never run uloop commands in parallel. The CLI already retried for up to 10 seconds, so wait for '%s' to complete and run the command again.", requestedToolName, + runningToolName, runningToolName) } diff --git a/cli/internal/cli/connection_retry.go b/cli/internal/cli/connection_retry.go index fffcfd08e..4336de52e 100644 --- a/cli/internal/cli/connection_retry.go +++ b/cli/internal/cli/connection_retry.go @@ -107,6 +107,11 @@ func sendWithTransientConnectionRetryAndResponseTimeout( continue } if !shouldRetryUndispatchedConnection(err, outcome) { + // A transport error caused by the expiring retry window must not mask a busy + // response seen earlier; busy is the truer diagnosis. + if err != nil && retryContext.Err() != nil && isUnityServerBusyRPCError(lastErr) { + return lastOutcome, lastErr + } return outcome, err } @@ -116,6 +121,11 @@ func sendWithTransientConnectionRetryAndResponseTimeout( if ctx.Err() != nil { return outcome, ctx.Err() } + // A busy response seen during the window is the truer diagnosis than a + // final dial cut short by the expiring retry context. + if isUnityServerBusyRPCError(lastErr) { + return lastOutcome, lastErr + } return outcome, unityServerNotRespondingError{ projectRoot: connection.ProjectRoot, endpoint: connection.Endpoint.Address, diff --git a/cli/internal/cli/connection_retry_test.go b/cli/internal/cli/connection_retry_test.go index ac06209ed..7dfebc048 100644 --- a/cli/internal/cli/connection_retry_test.go +++ b/cli/internal/cli/connection_retry_test.go @@ -470,12 +470,16 @@ func TestSendWithTransientConnectionRetryReturnsBusyAfterRetryWindow(t *testing. if err != nil { return } - if _, err := unityipc.Read(bufio.NewReader(conn)); err != nil { - _ = conn.Close() - return - } - _ = unityipc.Write(conn, []byte(busy)) - _ = conn.Close() + // Serve concurrently so rapid retry reconnects never queue behind a slow handler. + go func() { + defer func() { + _ = conn.Close() + }() + if _, readErr := unityipc.Read(bufio.NewReader(conn)); readErr != nil { + return + } + _ = unityipc.Write(conn, []byte(busy)) + }() } }() diff --git a/cli/internal/cli/error_envelope_test.go b/cli/internal/cli/error_envelope_test.go index 42592bf91..6dd161d64 100644 --- a/cli/internal/cli/error_envelope_test.go +++ b/cli/internal/cli/error_envelope_test.go @@ -231,7 +231,7 @@ func TestClassifyServerBusyRPCError(t *testing.T) { if !cliErr.Retryable || !cliErr.SafeToRetry { t.Fatalf("retry flags mismatch: %#v", cliErr) } - expectedMessage := "'get-logs' was not executed because Unity is busy running 'compile'. Retry after the running tool completes." + expectedMessage := "'get-logs' was not executed because Unity is busy running 'compile'. uloop is single-flight by design; never run uloop commands in parallel. The CLI already retried for up to 10 seconds, so wait for 'compile' to complete and run the command again." if cliErr.Message != expectedMessage { t.Fatalf("message mismatch: %s", cliErr.Message) } @@ -263,7 +263,7 @@ func TestWriteClassifiedServerBusyRPCErrorWritesBusyStatus(t *testing.T) { if envelope.Status != cliStatusBusy { t.Fatalf("status mismatch: %#v", envelope) } - expectedMessage := "'get-logs' was not executed because Unity is busy running 'compile'. Retry after the running tool completes." + expectedMessage := "'get-logs' was not executed because Unity is busy running 'compile'. uloop is single-flight by design; never run uloop commands in parallel. The CLI already retried for up to 10 seconds, so wait for 'compile' to complete and run the command again." if envelope.Message != expectedMessage { t.Fatalf("message mismatch: %#v", envelope) } From 5ee55fa995772d25bf8e20d09e345c05380b7066 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 11 Jun 2026 16:27:58 +0900 Subject: [PATCH 17/31] Hint that game state may have moved past an unhit pause point marker Block-breaker E2E sessions showed fast-progressing games can leave the marker code path behind (state returns to Ready/GameOver) before the wait starts, so the never-hit timeout hint now tells agents to re-trigger the code path instead of only re-checking the marker id. --- cli/internal/cli/pause_point_errors.go | 2 +- cli/internal/cli/pause_point_wait_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/internal/cli/pause_point_errors.go b/cli/internal/cli/pause_point_errors.go index c87c937f3..8c6a19edc 100644 --- a/cli/internal/cli/pause_point_errors.go +++ b/cli/internal/cli/pause_point_errors.go @@ -72,7 +72,7 @@ func pausePointTimeoutHint(response pausePointStatusResponse) string { return pausePointHintEditorAlreadyPaused } if response.HitCount == 0 && response.Status == pausePointStatusEnabled { - return "Marker was enabled but never hit. Confirm the id matches UloopPausePoint.Pause(\"\") and that the code path was executed." + return "Marker was enabled but never hit. Confirm the id matches UloopPausePoint.Pause(\"\") and that the code path was executed. In fast-progressing games the state may have already moved past the marker (for example back to Ready or GameOver), so re-trigger the code path and wait again." } return "" } diff --git a/cli/internal/cli/pause_point_wait_test.go b/cli/internal/cli/pause_point_wait_test.go index f4438b742..dd08f8733 100644 --- a/cli/internal/cli/pause_point_wait_test.go +++ b/cli/internal/cli/pause_point_wait_test.go @@ -526,7 +526,7 @@ func TestPausePointTimeoutErrorIncludesDiagnosisHint(t *testing.T) { { name: "marker never hit", response: pausePointStatusResponse{Id: "jump", Status: pausePointStatusEnabled, IsPlaying: true, HitCount: 0}, - wantHint: "Marker was enabled but never hit. Confirm the id matches UloopPausePoint.Pause(\"\") and that the code path was executed.", + wantHint: "Marker was enabled but never hit. Confirm the id matches UloopPausePoint.Pause(\"\") and that the code path was executed. In fast-progressing games the state may have already moved past the marker (for example back to Ready or GameOver), so re-trigger the code path and wait again.", }, } From 2f2dbd00683dd5ebd9b1bccc81bb33ea84d34378 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 11 Jun 2026 16:27:58 +0900 Subject: [PATCH 18/31] Stop COMPILE_WAIT_TIMEOUT from reading as a frozen Editor An agent terminated a whole session after misreading this timeout as an Editor freeze, even though Unity answered the next command instantly. The message now states the Editor is not necessarily frozen, and the next actions walk through a responsiveness probe (get-logs) before retrying compile, with launch -r only as the last resort. --- cli/internal/cli/error_envelope_test.go | 23 +++++++++++++++++++---- cli/internal/cli/execution_errors.go | 8 ++++++-- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/cli/internal/cli/error_envelope_test.go b/cli/internal/cli/error_envelope_test.go index 6dd161d64..1a4580c11 100644 --- a/cli/internal/cli/error_envelope_test.go +++ b/cli/internal/cli/error_envelope_test.go @@ -420,7 +420,9 @@ func TestClassifyInstallUnsupportedOS(t *testing.T) { } } -// Tests that compile wait timeout guidance uses the current value-less boolean flag syntax. +// Tests that compile wait timeout guidance teaches the caller to verify Editor +// responsiveness instead of assuming a freeze, because agents have terminated +// whole sessions after misreading this timeout as a frozen Editor. func TestCompileWaitTimeoutError(t *testing.T) { cliErr := compileWaitTimeoutError("/tmp/MyProject") @@ -433,9 +435,22 @@ func TestCompileWaitTimeoutError(t *testing.T) { if cliErr.ProjectRoot != "/tmp/MyProject" { t.Fatalf("project root mismatch: %#v", cliErr) } - expectedRetryAction := "Retry `uloop compile` after Unity becomes responsive." - if len(cliErr.NextActions) == 0 || cliErr.NextActions[0] != expectedRetryAction { - t.Fatalf("retry action mismatch: %#v", cliErr.NextActions) + expectedMessage := "Compile status wait timed out after 180000ms. This does not mean the Unity Editor is frozen; the compile may simply still be running." + if cliErr.Message != expectedMessage { + t.Fatalf("message mismatch: %#v", cliErr.Message) + } + expectedActions := []string{ + "Run a light command such as `uloop get-logs --max-count 1` to check whether Unity is responsive before treating this as a freeze.", + "If Unity responds, retry `uloop compile`; the previous compile likely finished in the meantime.", + "Only if Unity does not respond to any command, restart it with `uloop launch -r`.", + } + if len(cliErr.NextActions) != len(expectedActions) { + t.Fatalf("next actions mismatch: %#v", cliErr.NextActions) + } + for i, expected := range expectedActions { + if cliErr.NextActions[i] != expected { + t.Fatalf("next action %d mismatch: %#v", i, cliErr.NextActions) + } } } diff --git a/cli/internal/cli/execution_errors.go b/cli/internal/cli/execution_errors.go index a44c90549..765b9f33f 100644 --- a/cli/internal/cli/execution_errors.go +++ b/cli/internal/cli/execution_errors.go @@ -4,13 +4,17 @@ func compileWaitTimeoutError(projectRoot string) cliError { return cliError{ ErrorCode: errorCodeCompileWaitTimeout, Phase: errorPhaseCompileWaiting, - Message: "Compile status wait timed out after 180000ms.", + Message: "Compile status wait timed out after 180000ms. This does not mean the Unity Editor is frozen; the compile may simply still be running.", Retryable: true, SafeToRetry: true, ProjectRoot: projectRoot, Command: compileCommandName, + // Agents have terminated whole sessions after misreading this timeout as a + // frozen Editor, so the guidance must walk them through a responsiveness check. NextActions: []string{ - "Retry `uloop compile` after Unity becomes responsive.", + "Run a light command such as `uloop get-logs --max-count 1` to check whether Unity is responsive before treating this as a freeze.", + "If Unity responds, retry `uloop compile`; the previous compile likely finished in the meantime.", + "Only if Unity does not respond to any command, restart it with `uloop launch -r`.", }, } } From 63abee11e8a7e9da5d8ecfd43c1b2f43650b6751 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 11 Jun 2026 16:31:14 +0900 Subject: [PATCH 19/31] Auto-refresh previously installed skill targets on skills install Two experiment runs in a row hit the same failure mode: .codex/skills was installed once, later installs only passed --claude/--agents, and Codex agents kept reading retired flags from the stale copy. Install now detects any known target that already holds a uloop skill and refreshes it even when its flag is omitted, and the install help documents this behavior. --- cli/internal/cli/skills.go | 56 +++++++++++++++++++++- cli/internal/cli/skills_display.go | 5 ++ cli/internal/cli/skills_test.go | 76 ++++++++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 1 deletion(-) diff --git a/cli/internal/cli/skills.go b/cli/internal/cli/skills.go index 80e08f654..cb4d5a923 100644 --- a/cli/internal/cli/skills.go +++ b/cli/internal/cli/skills.go @@ -264,7 +264,17 @@ func runSkillsInstall(projectRoot string, skills []skillDefinition, options skil writeLine(stdout, "") writeFormat(stdout, "Installing uloop skills (%s)...\n", skillLocationName(options.global)) writeLine(stdout, "") - for _, target := range options.targets { + // Targets installed earlier but omitted from this invocation would keep stale + // skill copies that contradict the CLI, so refresh every detected install. + autoRefreshTargets, err := detectInstalledSkillTargets(projectRoot, skills, options) + if err != nil { + writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: skillsCommandName}) + return 1 + } + for _, autoTarget := range autoRefreshTargets { + writeFormat(stdout, "Auto-refreshing %s: an existing uloop skill install was detected there.\n\n", autoTarget.displayName) + } + for _, target := range append(append([]skillTarget{}, options.targets...), autoRefreshTargets...) { result, err := installSkillsForTarget(projectRoot, target, skills, options.global, groupManagedSkillsForOptions(options)) if err != nil { writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: skillsCommandName}) @@ -311,6 +321,50 @@ func runSkillsUninstall(projectRoot string, skills []skillDefinition, options sk return 0 } +// detectInstalledSkillTargets returns known targets that already contain at least one +// of the current uloop skills but were not requested in this invocation. +func detectInstalledSkillTargets(projectRoot string, skills []skillDefinition, options skillCommandOptions) ([]skillTarget, error) { + requestedDirs := map[string]bool{} + for _, target := range options.targets { + requestedDirs[target.projectDir] = true + } + + detected := []skillTarget{} + for _, targetID := range defaultSkillTargetIDs { + target := targetConfigs[targetID] + if requestedDirs[target.projectDir] { + continue + } + baseDir, err := getSkillsBaseDir(projectRoot, target, options.global) + if err != nil { + return nil, err + } + installed, err := hasAnyInstalledSkill(baseDir, skills) + if err != nil { + return nil, err + } + if installed { + detected = append(detected, target) + requestedDirs[target.projectDir] = true + } + } + return detected, nil +} + +func hasAnyInstalledSkill(baseDir string, skills []skillDefinition) (bool, error) { + for _, skill := range skills { + for _, grouped := range []bool{false, true} { + skillFile := filepath.Join(getPreferredSkillDir(baseDir, skill.name, grouped), skillFileName) + if _, err := os.Stat(skillFile); err == nil { + return true, nil + } else if !os.IsNotExist(err) { + return false, err + } + } + } + return false, nil +} + type skillInstallResult struct { installed int updated int diff --git a/cli/internal/cli/skills_display.go b/cli/internal/cli/skills_display.go index 61c80ece5..13409bb15 100644 --- a/cli/internal/cli/skills_display.go +++ b/cli/internal/cli/skills_display.go @@ -75,6 +75,11 @@ func printSkillsSubcommandHelp(command string, stdout io.Writer) { writeLine(stdout, " --windsurf") writeLine(stdout, " --antigravity") writeLine(stdout, "") + if command == "install" { + writeLine(stdout, "Targets that already contain uloop skills are refreshed automatically,") + writeLine(stdout, "even when their flag is omitted, so previously installed copies never go stale.") + writeLine(stdout, "") + } printGlobalOptionsHelp(stdout) } diff --git a/cli/internal/cli/skills_test.go b/cli/internal/cli/skills_test.go index 395c18dce..ec7c46d1a 100644 --- a/cli/internal/cli/skills_test.go +++ b/cli/internal/cli/skills_test.go @@ -748,6 +748,82 @@ func TestTryHandleSkillsRequestRejectsUnknownSubcommandWithoutProject(t *testing } } +// Tests that install refreshes targets that already hold uloop skills even when +// they were not requested, because stale per-agent copies (e.g. .codex) have +// repeatedly misled agents with retired flags. +func TestRunSkillsInstallAutoRefreshesPreviouslyInstalledTargets(t *testing.T) { + projectRoot := t.TempDir() + sourceDir := filepath.Join(projectRoot, "source", "Skill") + writeSkillFile(t, sourceDir, `--- +name: uloop-sample +--- + +# new content +`) + skill := skillDefinition{ + name: "uloop-sample", + content: []byte("---\nname: uloop-sample\n---\n\n# new content\n"), + sourceDirectory: sourceDir, + } + + codexTarget := targetConfigs["codex"] + codexBaseDir, err := getSkillsBaseDir(projectRoot, codexTarget, false) + if err != nil { + t.Fatalf("getSkillsBaseDir failed: %v", err) + } + staleDir := getPreferredSkillDir(codexBaseDir, skill.name, false) + writeSkillFile(t, staleDir, "---\nname: uloop-sample\n---\n\n# stale content\n") + + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + options := skillCommandOptions{targets: []skillTarget{targetConfigs["claude"]}} + code := runSkillsInstall(projectRoot, []skillDefinition{skill}, options, stdout, stderr) + + if code != 0 { + t.Fatalf("install should succeed: code=%d stderr=%s", code, stderr.String()) + } + refreshedContent, err := os.ReadFile(filepath.Join(staleDir, "SKILL.md")) + if err != nil { + t.Fatalf("codex skill should still exist: %v", err) + } + if !strings.Contains(string(refreshedContent), "# new content") { + t.Fatalf("codex skill should be refreshed: %s", string(refreshedContent)) + } + if !strings.Contains(stdout.String(), "Codex CLI") { + t.Fatalf("output should mention the auto-refreshed target: %s", stdout.String()) + } +} + +// Tests that install never creates skill directories for targets that were +// neither requested nor previously installed. +func TestRunSkillsInstallLeavesUninstalledTargetsUntouched(t *testing.T) { + projectRoot := t.TempDir() + sourceDir := filepath.Join(projectRoot, "source", "Skill") + writeSkillFile(t, sourceDir, `--- +name: uloop-sample +--- + +# sample +`) + skill := skillDefinition{ + name: "uloop-sample", + content: []byte("---\nname: uloop-sample\n---\n\n# sample\n"), + sourceDirectory: sourceDir, + } + + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + options := skillCommandOptions{targets: []skillTarget{targetConfigs["claude"]}} + code := runSkillsInstall(projectRoot, []skillDefinition{skill}, options, stdout, stderr) + + if code != 0 { + t.Fatalf("install should succeed: code=%d stderr=%s", code, stderr.String()) + } + if _, err := os.Stat(filepath.Join(projectRoot, ".codex")); !os.IsNotExist(err) { + t.Fatalf(".codex should not be created without a prior install: %v", err) + } +} + func writeTestSkill(t *testing.T, projectRoot string, relativeDir string, content string) { t.Helper() writeSkillFile(t, filepath.Join(projectRoot, filepath.FromSlash(relativeDir)), content) From 303e8f30a4f8adb6d1f7dfcb0c662afff17dce4f Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 11 Jun 2026 16:32:57 +0900 Subject: [PATCH 20/31] Save RenderTexture.active before Blit in screenshot capture paths Graphics.Blit (and the internal window-capture call) leave the destination assigned to RenderTexture.active, so saving the "previous" target after them captured the temporary itself. Restoring that value and then releasing the temporary emitted "Releasing render texture that is set to be RenderTexture.active\!" warnings, which polluted the zero-warning check agents run at the end of E2E sessions. All three capture paths now save before the call and restore before release. --- .../Screenshot/EditorWindowCaptureUtility.cs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/EditorWindowCaptureUtility.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/EditorWindowCaptureUtility.cs index df290bce9..6954016bd 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/EditorWindowCaptureUtility.cs +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/EditorWindowCaptureUtility.cs @@ -97,11 +97,14 @@ public static EditorWindow[] FindWindowsByName(string windowName, WindowMatchMod { descriptor.sRGB = false; } + // Capture the caller's active target before the capture call, which may + // reassign RenderTexture.active to the temporary internally; saving afterwards + // would release a still-active render texture and emit a Console warning. + RenderTexture previousActive = RenderTexture.active; RenderTexture rt = RenderTexture.GetTemporary(descriptor); InternalEditorUtilityBridge.CaptureEditorWindow(window, rt); - RenderTexture previousActive = RenderTexture.active; RenderTexture.active = rt; Texture2D texture = new(width, height, TextureFormat.RGB24, false); @@ -166,10 +169,13 @@ public static string[] GetOpenWindowNames() { flipDescriptor.sRGB = false; } + // Capture the caller's active target before Blit: Blit leaves the destination + // assigned to RenderTexture.active, so saving afterwards would "restore" the + // temporary itself and releasing it would warn about an active render texture. + RenderTexture previousActive = RenderTexture.active; RenderTexture flipped = RenderTexture.GetTemporary(flipDescriptor); Graphics.Blit(rt, flipped, new Vector2(1f, -1f), new Vector2(0f, 1f)); - RenderTexture previousActive = RenderTexture.active; RenderTexture.active = flipped; Texture2D texture = new(rt.width, rt.height, TextureFormat.RGB24, false); @@ -194,13 +200,17 @@ private static Texture2D ApplyResolutionScaling(Texture2D originalTexture, float Texture2D scaledTexture = new(newWidth, newHeight, originalTexture.format, false); + // Same active-target discipline as the capture paths: save before Blit, + // restore before release, so the caller's target survives and the temporary + // is never released while active. + RenderTexture previousActive = RenderTexture.active; RenderTexture rt = RenderTexture.GetTemporary(newWidth, newHeight); Graphics.Blit(originalTexture, rt); RenderTexture.active = rt; scaledTexture.ReadPixels(new Rect(0, 0, newWidth, newHeight), 0, 0); scaledTexture.Apply(); - RenderTexture.active = null; + RenderTexture.active = previousActive; RenderTexture.ReleaseTemporary(rt); UnityEngine.Object.DestroyImmediate(originalTexture); From e13d2f2584ac0a246692137a1281117361e8e2ed Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 11 Jun 2026 16:36:43 +0900 Subject: [PATCH 21/31] Report PressEdgeObserved on pause-point interrupted keyboard input Pause-point interruption is the most common path in marker-driven E2E sessions, yet the interrupted result dropped PressEdgeObserved and agents saw null exactly where they wanted the edge evidence. Press and KeyDown now carry their observation into the interrupted result, and the skill doc spells out when the field is null (KeyUp and timeouts only). --- .../Tests/PlayMode/SimulateKeyboardTests.cs | 9 +++++++++ .../SimulateKeyboardUseCase.cs | 20 +++++++++---------- .../SimulateKeyboard/Skill/SKILL.md | 2 +- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/Assets/Tests/PlayMode/SimulateKeyboardTests.cs b/Assets/Tests/PlayMode/SimulateKeyboardTests.cs index 78144c8c9..f4f306c05 100644 --- a/Assets/Tests/PlayMode/SimulateKeyboardTests.cs +++ b/Assets/Tests/PlayMode/SimulateKeyboardTests.cs @@ -193,6 +193,12 @@ public IEnumerator Press_WhenUnityPausesDuringObservation_Should_CompleteAsPause Assert.AreEqual("Space", lastResponse.KeyName); Assert.IsNull(lastResponse.PausePointId); Assert.IsNull(lastResponse.PausePointHitCount); + Assert.IsTrue( + lastResponse.PressEdgeObserved.HasValue, + "Interrupted presses must still report whether the press edge was observed."); + Assert.IsTrue( + lastResponse.PressEdgeObserved!.Value, + "The press reached isPressed through gameplay updates, so the edge must have been observed."); Assert.IsFalse(keyboard[Key.Space].isPressed, "Pause-point interruption should release the injected key state."); Assert.IsFalse(SimulateKeyboardOverlayState.IsActive, "Pause-point interruption should clear keyboard overlay state."); } @@ -229,6 +235,9 @@ public IEnumerator Press_WhenPausePointMarkerHits_Should_ReturnMarkerDetails() Assert.IsTrue(lastResponse.InterruptedByPausePoint); Assert.AreEqual("space-press", lastResponse.PausePointId); Assert.AreEqual(1, lastResponse.PausePointHitCount); + Assert.IsTrue( + lastResponse.PressEdgeObserved.HasValue, + "Marker-interrupted presses must still report whether the press edge was observed."); Assert.IsFalse(keyboard[Key.Space].isPressed, "Marker interruption should release the injected key state."); } diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs index 10bc5fc79..4a457d08c 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs @@ -230,7 +230,7 @@ private async Task ExecutePress( if (waitOutcome == InputSimulationWaitOutcome.Paused) { - return InterruptedPressResult(keyName); + return InterruptedKeyResult(UnityCliLoopKeyboardAction.Press, keyName, pressEdgeObserved); } if (waitOutcome == InputSimulationWaitOutcome.TimedOut) @@ -313,7 +313,7 @@ private async Task ExecuteKeyDown(Keyboard if (waitOutcome == InputSimulationWaitOutcome.Paused) { - return InterruptedKeyResult(UnityCliLoopKeyboardAction.KeyDown, keyName); + return InterruptedKeyResult(UnityCliLoopKeyboardAction.KeyDown, keyName, pressEdgeObserved); } if (waitOutcome == InputSimulationWaitOutcome.TimedOut) @@ -366,7 +366,7 @@ private async Task ExecuteKeyUp(Keyboard k .ConfigureAwait(false); if (waitOutcome == InputSimulationWaitOutcome.Paused) { - return InterruptedKeyResult(UnityCliLoopKeyboardAction.KeyUp, keyName); + return InterruptedKeyResult(UnityCliLoopKeyboardAction.KeyUp, keyName, null); } if (waitOutcome == InputSimulationWaitOutcome.TimedOut) @@ -392,14 +392,13 @@ private static string NormalizeKeyName(string keyName) return keyName; } - private static UnityCliLoopKeyboardSimulationResult InterruptedPressResult(string keyName) - { - return InterruptedKeyResult(UnityCliLoopKeyboardAction.Press, keyName); - } - + // pressEdgeObserved stays nullable because KeyUp has no press edge to report; + // Press/KeyDown must pass their observation so pause-point interruptions (the + // most common E2E path) do not silently drop the field. private static UnityCliLoopKeyboardSimulationResult InterruptedKeyResult( UnityCliLoopKeyboardAction action, - string keyName) + string keyName, + bool? pressEdgeObserved) { UnityCliLoopKeyboardSimulationResult result = new() { @@ -407,7 +406,8 @@ private static UnityCliLoopKeyboardSimulationResult InterruptedKeyResult( Message = $"Keyboard input stopped because Unity paused during Pause Point inspection. Key '{keyName}' was released from Unity CLI Loop bookkeeping.", Action = action.ToString(), KeyName = keyName, - InterruptedByPausePoint = true + InterruptedByPausePoint = true, + PressEdgeObserved = pressEdgeObserved }; AttachPausePointHit(result); return result; diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md index aa11d4c8a..9f0b8e5de 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md @@ -97,7 +97,7 @@ Returns JSON with: - `InterruptedByPausePoint` (boolean): True when Unity paused during Pause Point inspection and the input bookkeeping was safely released - `PausePointId` (string, nullable): The marker id when a `UloopPausePoint.Pause` marker caused the interruption - `PausePointHitCount` (integer, nullable): The marker hit count when a `UloopPausePoint.Pause` marker caused the interruption -- `PressEdgeObserved` (boolean, nullable): For `Press` and `KeyDown`, whether the press edge (`wasPressedThisFrame`) was actually visible inside a gameplay input update. `false` means the CLI succeeded but gameplay polling most likely missed the edge (e.g. the press was consumed by an editor-only input update) — retry the input or verify with a focused log instead of trusting `Success` alone. `null` for `KeyUp` +- `PressEdgeObserved` (boolean, nullable): For `Press` and `KeyDown`, whether the press edge (`wasPressedThisFrame`) was actually visible inside a gameplay input update. `false` means the CLI succeeded but gameplay polling most likely missed the edge (e.g. the press was consumed by an editor-only input update) — retry the input or verify with a focused log instead of trusting `Success` alone. `null` only for `KeyUp` and for timed-out responses; pause-point interruptions still report the observed value ## Prerequisites From 6f3fa9bfa562d36009f328fa0f8ccdb15c7c1e6c Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 11 Jun 2026 16:46:31 +0900 Subject: [PATCH 22/31] List every pause point hit on interrupted input simulation responses One key press or mouse action can hit several markers in the same frame, but the response only named the latest one, so agents had to issue extra status calls to find the rest. The registry now keeps all hit snapshots (cleared alongside the existing latest-hit bookkeeping), and keyboard/mouse interruption responses expose them as a PausePointHits array of {Id, HitCount} in hit order. --- Assets/Tests/Editor/PausePointTests.cs | 29 ++++++++++++++ .../Tests/PlayMode/SimulateKeyboardTests.cs | 38 +++++++++++++++++++ .../UnityCliLoopInputSimulationTypes.cs | 12 ++++++ .../SimulateKeyboardResponse.cs | 3 ++ .../SimulateKeyboard/SimulateKeyboardTool.cs | 1 + .../SimulateKeyboardUseCase.cs | 22 +++++++++++ .../SimulateKeyboard/Skill/SKILL.md | 1 + .../SimulateMouseInputResponse.cs | 3 ++ .../SimulateMouseInputTool.cs | 1 + .../SimulateMouseInputUseCase.cs | 22 +++++++++++ .../SimulateMouseInput/Skill/SKILL.md | 1 + .../PausePoints/UloopPausePointRegistry.cs | 13 +++++++ 12 files changed, 146 insertions(+) diff --git a/Assets/Tests/Editor/PausePointTests.cs b/Assets/Tests/Editor/PausePointTests.cs index 90c83c23d..019e77d52 100644 --- a/Assets/Tests/Editor/PausePointTests.cs +++ b/Assets/Tests/Editor/PausePointTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Threading; using System.Threading.Tasks; @@ -84,6 +85,34 @@ public void Pause_WhenPausePointIsEnabled_StoresLatestHitSnapshot() Assert.That(snapshot.HitCount, Is.EqualTo(1)); } + [Test] + public void Pause_WhenMultiplePausePointsHit_StoresAllHitSnapshotsInOrder() + { + // Verifies input interruption responses can list every marker hit, not just the latest. + UloopPausePointRegistry.Enable("jump", 30); + UloopPausePointRegistry.Enable("land", 30); + + UloopPausePoint.Pause("jump"); + UloopPausePoint.Pause("land"); + + IReadOnlyList hits = UloopPausePointRegistry.GetHitSnapshots(); + Assert.That(hits.Count, Is.EqualTo(2)); + Assert.That(hits[0].Id, Is.EqualTo("jump")); + Assert.That(hits[1].Id, Is.EqualTo("land")); + } + + [Test] + public void Enable_WhenSamePausePointWasHit_RemovesItFromHitSnapshots() + { + // Verifies re-enabling a hit marker drops its stale entry from the hit list. + UloopPausePointRegistry.Enable("jump", 30); + UloopPausePoint.Pause("jump"); + + UloopPausePointRegistry.Enable("jump", 30); + + Assert.That(UloopPausePointRegistry.GetHitSnapshots(), Is.Empty); + } + [Test] public void GetStatus_WhenTimeoutPasses_ExpiresAndDisarms() { diff --git a/Assets/Tests/PlayMode/SimulateKeyboardTests.cs b/Assets/Tests/PlayMode/SimulateKeyboardTests.cs index f4f306c05..db4890ca8 100644 --- a/Assets/Tests/PlayMode/SimulateKeyboardTests.cs +++ b/Assets/Tests/PlayMode/SimulateKeyboardTests.cs @@ -241,6 +241,44 @@ public IEnumerator Press_WhenPausePointMarkerHits_Should_ReturnMarkerDetails() Assert.IsFalse(keyboard[Key.Space].isPressed, "Marker interruption should release the injected key state."); } + [UnityTest] + public IEnumerator Press_WhenMultiplePausePointMarkersHit_Should_ListAllHits() + { + // Verifies the response lists every marker hit during the press, not just the latest. + yield return null; + + UloopPausePointRegistry.ConfigureForTests( + new FakePausePointPauseController(), + () => new DateTime(2026, 6, 3, 0, 0, 0, DateTimeKind.Utc)); + UloopPausePointRegistry.Enable("space-press", 30); + UloopPausePointRegistry.Enable("space-press-followup", 30); + SimulateKeyboardSchema parameters = new() + { + Action = UnityCliLoopKeyboardAction.Press, + Key = "Space", + Duration = 1f + }; + Task task = + tool.ExecuteWithCancellationAsync(parameters, CancellationToken.None); + + yield return new WaitUntil(() => keyboard[Key.Space].isPressed || task.IsCompleted); + Assert.IsFalse(task.IsCompleted, "The test must pause during the press observation window."); + + UloopPausePoint.Pause("space-press"); + UloopPausePoint.Pause("space-press-followup"); + InputSystemUpdateHelper.ConfigurePauseProviderForTests(() => true); + yield return WaitForTask(task); + InputSystemUpdateHelper.ResetPauseProviderForTests(); + + lastResponse = task.Result; + Assert.IsTrue(lastResponse.Success); + Assert.IsTrue(lastResponse.InterruptedByPausePoint); + Assert.IsNotNull(lastResponse.PausePointHits, "All hit markers must be listed."); + Assert.AreEqual(2, lastResponse.PausePointHits!.Count); + Assert.AreEqual("space-press", lastResponse.PausePointHits[0].Id); + Assert.AreEqual("space-press-followup", lastResponse.PausePointHits[1].Id); + } + [UnityTest] public IEnumerator Press_Cancellation_Should_ClearPressOverlay() { diff --git a/Packages/src/Editor/FirstPartyTools/Common/InputSimulation/UnityCliLoopInputSimulationTypes.cs b/Packages/src/Editor/FirstPartyTools/Common/InputSimulation/UnityCliLoopInputSimulationTypes.cs index 29d6d4dc6..ef9910485 100644 --- a/Packages/src/Editor/FirstPartyTools/Common/InputSimulation/UnityCliLoopInputSimulationTypes.cs +++ b/Packages/src/Editor/FirstPartyTools/Common/InputSimulation/UnityCliLoopInputSimulationTypes.cs @@ -1,4 +1,5 @@ #nullable enable +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -86,6 +87,15 @@ public sealed class UnityCliLoopKeyboardSimulationRequest public float Duration { get; set; } } + /// + /// Identifies one pause point marker that was hit while an input simulation ran. + /// + public sealed class UnityCliLoopPausePointHit + { + public string Id { get; set; } = ""; + public int HitCount { get; set; } + } + /// /// Carries the result data produced by Unity CLI Loop Keyboard Simulation behavior. /// @@ -98,6 +108,7 @@ public sealed class UnityCliLoopKeyboardSimulationResult public bool InterruptedByPausePoint { get; set; } public string? PausePointId { get; set; } public int? PausePointHitCount { get; set; } + public List? PausePointHits { get; set; } public bool? PressEdgeObserved { get; set; } } @@ -131,6 +142,7 @@ public sealed class UnityCliLoopMouseInputSimulationResult public bool InterruptedByPausePoint { get; set; } public string? PausePointId { get; set; } public int? PausePointHitCount { get; set; } + public List? PausePointHits { get; set; } } /// diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs index 719ae8472..dc26f95cb 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs @@ -1,5 +1,7 @@ #nullable enable +using System.Collections.Generic; + using io.github.hatayama.UnityCliLoop.ToolContracts; namespace io.github.hatayama.UnityCliLoop.FirstPartyTools @@ -16,6 +18,7 @@ public class SimulateKeyboardResponse : UnityCliLoopToolResponse public bool InterruptedByPausePoint { get; set; } public string? PausePointId { get; set; } public int? PausePointHitCount { get; set; } + public List? PausePointHits { get; set; } public bool? PressEdgeObserved { get; set; } public SimulateKeyboardResponse() diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardTool.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardTool.cs index b4a386816..9c5f04175 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardTool.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardTool.cs @@ -51,6 +51,7 @@ private static SimulateKeyboardResponse ToResponse(UnityCliLoopKeyboardSimulatio InterruptedByPausePoint = result.InterruptedByPausePoint, PausePointId = result.PausePointId, PausePointHitCount = result.PausePointHitCount, + PausePointHits = result.PausePointHits, PressEdgeObserved = result.PressEdgeObserved, }; } diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs index 4a457d08c..8bdb86bdb 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs @@ -1,5 +1,6 @@ #nullable enable using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using UnityEditor; @@ -453,6 +454,27 @@ private static void AttachPausePointHit(UnityCliLoopKeyboardSimulationResult res result.PausePointId = snapshotId; result.PausePointHitCount = snapshot.HitCount; + result.PausePointHits = CollectPausePointHits(); + } + + // One input can hit several markers in the same frame; the representative + // PausePointId alone forced agents into extra status calls to find the others. + private static List CollectPausePointHits() + { + List hits = new(); + foreach (UloopPausePointSnapshot snapshot in UloopPausePointRegistry.GetHitSnapshots()) + { + if (!snapshot.IsHit || string.IsNullOrEmpty(snapshot.Id)) + { + continue; + } + hits.Add(new UnityCliLoopPausePointHit + { + Id = snapshot.Id, + HitCount = snapshot.HitCount + }); + } + return hits; } private static async Task FinalizePressOverlay(CancellationToken ct) diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md index 9f0b8e5de..e96064422 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md @@ -97,6 +97,7 @@ Returns JSON with: - `InterruptedByPausePoint` (boolean): True when Unity paused during Pause Point inspection and the input bookkeeping was safely released - `PausePointId` (string, nullable): The marker id when a `UloopPausePoint.Pause` marker caused the interruption - `PausePointHitCount` (integer, nullable): The marker hit count when a `UloopPausePoint.Pause` marker caused the interruption +- `PausePointHits` (array, nullable): Every marker hit during this input as `{Id, HitCount}` entries, in hit order. Read this when one input may trigger several markers; `PausePointId` only names the latest one - `PressEdgeObserved` (boolean, nullable): For `Press` and `KeyDown`, whether the press edge (`wasPressedThisFrame`) was actually visible inside a gameplay input update. `false` means the CLI succeeded but gameplay polling most likely missed the edge (e.g. the press was consumed by an editor-only input update) — retry the input or verify with a focused log instead of trusting `Success` alone. `null` only for `KeyUp` and for timed-out responses; pause-point interruptions still report the observed value ## Prerequisites diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputResponse.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputResponse.cs index e7c0bc12f..35024c79b 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputResponse.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputResponse.cs @@ -1,5 +1,7 @@ #nullable enable +using System.Collections.Generic; + using io.github.hatayama.UnityCliLoop.ToolContracts; namespace io.github.hatayama.UnityCliLoop.FirstPartyTools @@ -18,6 +20,7 @@ public class SimulateMouseInputResponse : UnityCliLoopToolResponse public bool InterruptedByPausePoint { get; set; } public string? PausePointId { get; set; } public int? PausePointHitCount { get; set; } + public List? PausePointHits { get; set; } public SimulateMouseInputResponse() { diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputTool.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputTool.cs index 16ce5fcb4..9b5840794 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputTool.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputTool.cs @@ -60,6 +60,7 @@ private static SimulateMouseInputResponse ToResponse(UnityCliLoopMouseInputSimul InterruptedByPausePoint = result.InterruptedByPausePoint, PausePointId = result.PausePointId, PausePointHitCount = result.PausePointHitCount, + PausePointHits = result.PausePointHits, }; } } diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs index d8e827799..4e313a212 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs @@ -1,5 +1,6 @@ #nullable enable using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using UnityEditor; @@ -612,6 +613,27 @@ private static void AttachPausePointHit(UnityCliLoopMouseInputSimulationResult r result.PausePointId = snapshotId; result.PausePointHitCount = snapshot.HitCount; + result.PausePointHits = CollectPausePointHits(); + } + + // One input can hit several markers in the same frame; the representative + // PausePointId alone forced agents into extra status calls to find the others. + private static List CollectPausePointHits() + { + List hits = new(); + foreach (UloopPausePointSnapshot snapshot in UloopPausePointRegistry.GetHitSnapshots()) + { + if (!snapshot.IsHit || string.IsNullOrEmpty(snapshot.Id)) + { + continue; + } + hits.Add(new UnityCliLoopPausePointHit + { + Id = snapshot.Id, + HitCount = snapshot.HitCount + }); + } + return hits; } private static async Task ReleaseButtonIfPossible(Mouse mouse, RuntimeMouseButton button) diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md index 1c8dea3b8..6ee53578f 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md @@ -119,5 +119,6 @@ Returns JSON with: - `InterruptedByPausePoint`: True when Unity paused during Pause Point inspection and the input bookkeeping was safely released - `PausePointId`: The id from `UloopPausePoint.Pause("")` when it caused the interruption - `PausePointHitCount`: The hit count for that `UloopPausePoint.Pause("")` +- `PausePointHits` (array, nullable): Every marker hit during this input as `{Id, HitCount}` entries, in hit order. Read this when one input may trigger several markers; `PausePointId` only names the latest one There is no `DeltaX`, `DeltaY`, `ScrollX`, `ScrollY`, `Duration`, or hit-element field in the response — only the issued action, button, target position, and Pause Point interruption state are echoed back. Verify visual outcome with a follow-up screenshot. diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs index ad16a7f8f..72c78f9c2 100644 --- a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs +++ b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs @@ -17,6 +17,9 @@ internal static class UloopPausePointRegistry private static IUloopPausePointPauseController _pauseController = new UnityEditorPausePointPauseController(); private static Func _nowProvider = () => DateTime.UtcNow; private static UloopPausePointSnapshot _latestHitSnapshot; + // One input can hit several markers in the same frame; tools need the full list, + // not just the latest hit, to report every marker that interrupted them. + private static readonly List _hitSnapshots = new(); public static UloopPausePointSnapshot Enable(string id, int timeoutSeconds) { @@ -114,9 +117,16 @@ public static UloopPausePointSnapshot Hit(string id) entry.RecordHit(now, _pauseController.IsPlaying, _pauseController.IsPaused); UloopPausePointSnapshot snapshot = entry.ToSnapshot(now, _pauseController); _latestHitSnapshot = snapshot; + _hitSnapshots.RemoveAll(hitSnapshot => hitSnapshot.Id == id); + _hitSnapshots.Add(snapshot); return snapshot; } + public static IReadOnlyList GetHitSnapshots() + { + return _hitSnapshots; + } + public static UloopPausePointSnapshot GetLatestHitSnapshot() { return _latestHitSnapshot; @@ -125,10 +135,12 @@ public static UloopPausePointSnapshot GetLatestHitSnapshot() public static void ClearLatestHitSnapshot() { _latestHitSnapshot = null; + _hitSnapshots.Clear(); } private static void ClearLatestHitSnapshotIfMatches(string id) { + _hitSnapshots.RemoveAll(hitSnapshot => hitSnapshot.Id == id); if (_latestHitSnapshot == null) { return; @@ -155,6 +167,7 @@ public static void ResetForTests() { Entries.Clear(); _latestHitSnapshot = null; + _hitSnapshots.Clear(); _pauseController = new UnityEditorPausePointPauseController(); _nowProvider = () => DateTime.UtcNow; } From d2efd6cdc9b891135128790700871f7926005690 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 11 Jun 2026 16:47:18 +0900 Subject: [PATCH 23/31] Document a time-freeze setup pattern for fast-progressing games Block-breaker style games advance state during E2E setup, so agents asked for a way to arrange scenarios before the marker is overrun. The pause point skill now shows the execute-dynamic-code Time.timeScale freeze/restore pattern as a setup aid, with an explicit caveat that it is not paused-frame proof. --- .../Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md index 24cac1f99..5dafca53b 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -52,6 +52,20 @@ If this command times out, the marker line was not reached while the command wai Use `uloop pause-point-status --id state-transition-applied` only when you need to confirm the marker is armed or inspect the current hit state. Add focused debug logs before the marker when local variables must be captured. +## Fast-Progressing Games + +When the game advances on its own (a ball keeps bouncing, blocks keep falling), the state you are arranging can move past the marker before the input command and the wait are even issued. Freeze game time during setup, then restore it before triggering the marker: + +```bash +# Freeze gameplay while arranging the scenario (input simulation still works) +uloop execute-dynamic-code --code "UnityEngine.Time.timeScale = 0f; return \"frozen\";" +# ... enable markers, position state, prepare assertions ... +uloop execute-dynamic-code --code "UnityEngine.Time.timeScale = 1f; return \"running\";" +# Now trigger the input and wait for the marker +``` + +Use this only as a setup aid. A `Time.timeScale` change is never paused-frame proof by itself, and physics-driven transitions resume the moment the scale returns to 1, so trigger and wait immediately after restoring it. + ## Marker Placement - Prefer natural runtime points after input has been consumed, such as after a command is accepted, a state value changes, an evaluation step resolves, or a dependent component is updated. From 3d9401881610b4fca6fe1d140e13835fb75f2375 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 11 Jun 2026 16:48:01 +0900 Subject: [PATCH 24/31] Regenerate installed skill copies from updated sources Propagates the PausePointHits field docs, the PressEdgeObserved null conditions, and the fast-progressing-game time-freeze pattern into the generated .claude/.agents copies via uloop skills install. --- .agents/skills/uloop-simulate-keyboard/SKILL.md | 3 ++- .agents/skills/uloop-simulate-mouse-input/SKILL.md | 1 + .agents/skills/uloop-wait-for-pause-point/SKILL.md | 14 ++++++++++++++ .claude/skills/uloop-simulate-keyboard/SKILL.md | 3 ++- .claude/skills/uloop-simulate-mouse-input/SKILL.md | 1 + .claude/skills/uloop-wait-for-pause-point/SKILL.md | 14 ++++++++++++++ 6 files changed, 34 insertions(+), 2 deletions(-) diff --git a/.agents/skills/uloop-simulate-keyboard/SKILL.md b/.agents/skills/uloop-simulate-keyboard/SKILL.md index aa11d4c8a..e96064422 100644 --- a/.agents/skills/uloop-simulate-keyboard/SKILL.md +++ b/.agents/skills/uloop-simulate-keyboard/SKILL.md @@ -97,7 +97,8 @@ Returns JSON with: - `InterruptedByPausePoint` (boolean): True when Unity paused during Pause Point inspection and the input bookkeeping was safely released - `PausePointId` (string, nullable): The marker id when a `UloopPausePoint.Pause` marker caused the interruption - `PausePointHitCount` (integer, nullable): The marker hit count when a `UloopPausePoint.Pause` marker caused the interruption -- `PressEdgeObserved` (boolean, nullable): For `Press` and `KeyDown`, whether the press edge (`wasPressedThisFrame`) was actually visible inside a gameplay input update. `false` means the CLI succeeded but gameplay polling most likely missed the edge (e.g. the press was consumed by an editor-only input update) — retry the input or verify with a focused log instead of trusting `Success` alone. `null` for `KeyUp` +- `PausePointHits` (array, nullable): Every marker hit during this input as `{Id, HitCount}` entries, in hit order. Read this when one input may trigger several markers; `PausePointId` only names the latest one +- `PressEdgeObserved` (boolean, nullable): For `Press` and `KeyDown`, whether the press edge (`wasPressedThisFrame`) was actually visible inside a gameplay input update. `false` means the CLI succeeded but gameplay polling most likely missed the edge (e.g. the press was consumed by an editor-only input update) — retry the input or verify with a focused log instead of trusting `Success` alone. `null` only for `KeyUp` and for timed-out responses; pause-point interruptions still report the observed value ## Prerequisites diff --git a/.agents/skills/uloop-simulate-mouse-input/SKILL.md b/.agents/skills/uloop-simulate-mouse-input/SKILL.md index 1c8dea3b8..6ee53578f 100644 --- a/.agents/skills/uloop-simulate-mouse-input/SKILL.md +++ b/.agents/skills/uloop-simulate-mouse-input/SKILL.md @@ -119,5 +119,6 @@ Returns JSON with: - `InterruptedByPausePoint`: True when Unity paused during Pause Point inspection and the input bookkeeping was safely released - `PausePointId`: The id from `UloopPausePoint.Pause("")` when it caused the interruption - `PausePointHitCount`: The hit count for that `UloopPausePoint.Pause("")` +- `PausePointHits` (array, nullable): Every marker hit during this input as `{Id, HitCount}` entries, in hit order. Read this when one input may trigger several markers; `PausePointId` only names the latest one There is no `DeltaX`, `DeltaY`, `ScrollX`, `ScrollY`, `Duration`, or hit-element field in the response — only the issued action, button, target position, and Pause Point interruption state are echoed back. Verify visual outcome with a follow-up screenshot. diff --git a/.agents/skills/uloop-wait-for-pause-point/SKILL.md b/.agents/skills/uloop-wait-for-pause-point/SKILL.md index 24cac1f99..5dafca53b 100644 --- a/.agents/skills/uloop-wait-for-pause-point/SKILL.md +++ b/.agents/skills/uloop-wait-for-pause-point/SKILL.md @@ -52,6 +52,20 @@ If this command times out, the marker line was not reached while the command wai Use `uloop pause-point-status --id state-transition-applied` only when you need to confirm the marker is armed or inspect the current hit state. Add focused debug logs before the marker when local variables must be captured. +## Fast-Progressing Games + +When the game advances on its own (a ball keeps bouncing, blocks keep falling), the state you are arranging can move past the marker before the input command and the wait are even issued. Freeze game time during setup, then restore it before triggering the marker: + +```bash +# Freeze gameplay while arranging the scenario (input simulation still works) +uloop execute-dynamic-code --code "UnityEngine.Time.timeScale = 0f; return \"frozen\";" +# ... enable markers, position state, prepare assertions ... +uloop execute-dynamic-code --code "UnityEngine.Time.timeScale = 1f; return \"running\";" +# Now trigger the input and wait for the marker +``` + +Use this only as a setup aid. A `Time.timeScale` change is never paused-frame proof by itself, and physics-driven transitions resume the moment the scale returns to 1, so trigger and wait immediately after restoring it. + ## Marker Placement - Prefer natural runtime points after input has been consumed, such as after a command is accepted, a state value changes, an evaluation step resolves, or a dependent component is updated. diff --git a/.claude/skills/uloop-simulate-keyboard/SKILL.md b/.claude/skills/uloop-simulate-keyboard/SKILL.md index aa11d4c8a..e96064422 100644 --- a/.claude/skills/uloop-simulate-keyboard/SKILL.md +++ b/.claude/skills/uloop-simulate-keyboard/SKILL.md @@ -97,7 +97,8 @@ Returns JSON with: - `InterruptedByPausePoint` (boolean): True when Unity paused during Pause Point inspection and the input bookkeeping was safely released - `PausePointId` (string, nullable): The marker id when a `UloopPausePoint.Pause` marker caused the interruption - `PausePointHitCount` (integer, nullable): The marker hit count when a `UloopPausePoint.Pause` marker caused the interruption -- `PressEdgeObserved` (boolean, nullable): For `Press` and `KeyDown`, whether the press edge (`wasPressedThisFrame`) was actually visible inside a gameplay input update. `false` means the CLI succeeded but gameplay polling most likely missed the edge (e.g. the press was consumed by an editor-only input update) — retry the input or verify with a focused log instead of trusting `Success` alone. `null` for `KeyUp` +- `PausePointHits` (array, nullable): Every marker hit during this input as `{Id, HitCount}` entries, in hit order. Read this when one input may trigger several markers; `PausePointId` only names the latest one +- `PressEdgeObserved` (boolean, nullable): For `Press` and `KeyDown`, whether the press edge (`wasPressedThisFrame`) was actually visible inside a gameplay input update. `false` means the CLI succeeded but gameplay polling most likely missed the edge (e.g. the press was consumed by an editor-only input update) — retry the input or verify with a focused log instead of trusting `Success` alone. `null` only for `KeyUp` and for timed-out responses; pause-point interruptions still report the observed value ## Prerequisites diff --git a/.claude/skills/uloop-simulate-mouse-input/SKILL.md b/.claude/skills/uloop-simulate-mouse-input/SKILL.md index 1c8dea3b8..6ee53578f 100644 --- a/.claude/skills/uloop-simulate-mouse-input/SKILL.md +++ b/.claude/skills/uloop-simulate-mouse-input/SKILL.md @@ -119,5 +119,6 @@ Returns JSON with: - `InterruptedByPausePoint`: True when Unity paused during Pause Point inspection and the input bookkeeping was safely released - `PausePointId`: The id from `UloopPausePoint.Pause("")` when it caused the interruption - `PausePointHitCount`: The hit count for that `UloopPausePoint.Pause("")` +- `PausePointHits` (array, nullable): Every marker hit during this input as `{Id, HitCount}` entries, in hit order. Read this when one input may trigger several markers; `PausePointId` only names the latest one There is no `DeltaX`, `DeltaY`, `ScrollX`, `ScrollY`, `Duration`, or hit-element field in the response — only the issued action, button, target position, and Pause Point interruption state are echoed back. Verify visual outcome with a follow-up screenshot. diff --git a/.claude/skills/uloop-wait-for-pause-point/SKILL.md b/.claude/skills/uloop-wait-for-pause-point/SKILL.md index 24cac1f99..5dafca53b 100644 --- a/.claude/skills/uloop-wait-for-pause-point/SKILL.md +++ b/.claude/skills/uloop-wait-for-pause-point/SKILL.md @@ -52,6 +52,20 @@ If this command times out, the marker line was not reached while the command wai Use `uloop pause-point-status --id state-transition-applied` only when you need to confirm the marker is armed or inspect the current hit state. Add focused debug logs before the marker when local variables must be captured. +## Fast-Progressing Games + +When the game advances on its own (a ball keeps bouncing, blocks keep falling), the state you are arranging can move past the marker before the input command and the wait are even issued. Freeze game time during setup, then restore it before triggering the marker: + +```bash +# Freeze gameplay while arranging the scenario (input simulation still works) +uloop execute-dynamic-code --code "UnityEngine.Time.timeScale = 0f; return \"frozen\";" +# ... enable markers, position state, prepare assertions ... +uloop execute-dynamic-code --code "UnityEngine.Time.timeScale = 1f; return \"running\";" +# Now trigger the input and wait for the marker +``` + +Use this only as a setup aid. A `Time.timeScale` change is never paused-frame proof by itself, and physics-driven transitions resume the moment the scale returns to 1, so trigger and wait immediately after restoring it. + ## Marker Placement - Prefer natural runtime points after input has been consumed, such as after a command is accepted, a state value changes, an evaluation step resolves, or a dependent component is updated. From 3a741f683bab69e2d5e7832058c5254daf543179 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 11 Jun 2026 19:12:21 +0900 Subject: [PATCH 25/31] Add a Step action and replace the timeScale freeze guidance with it Time.timeScale = 0 is unreliable for E2E setup: projects that read unscaled time keep advancing, and the value silently leaks into the next PlayMode session (an agent hit exactly this). control-play-mode now exposes Step - the Editor's Next Frame button (EditorApplication.Step) - which advances one frame and stays paused independent of timeScale. The pause point skill now teaches Pause/Step/Play for fast-progressing games instead of freezing time. --- .../Editor/ControlPlayModeUseCaseTests.cs | 17 +++++++++++++++++ .../CliOnlyTools~/PausePoint/Skill/SKILL.md | 18 +++++++++++------- .../ControlPlayMode/ControlPlayModeSchema.cs | 3 ++- .../ControlPlayMode/ControlPlayModeUseCase.cs | 13 +++++++++++++ .../ControlPlayMode/Skill/SKILL.md | 6 +++++- 5 files changed, 48 insertions(+), 9 deletions(-) diff --git a/Assets/Tests/Editor/ControlPlayModeUseCaseTests.cs b/Assets/Tests/Editor/ControlPlayModeUseCaseTests.cs index 154a4c1a1..02cb945bb 100644 --- a/Assets/Tests/Editor/ControlPlayModeUseCaseTests.cs +++ b/Assets/Tests/Editor/ControlPlayModeUseCaseTests.cs @@ -36,6 +36,23 @@ public async Task ExecuteAsync_WhenStatusOnly_ReturnsCurrentPlayModeState() Assert.That(response.Message, Is.EqualTo("Play mode status")); } + [Test] + public async Task ExecuteAsync_WhenStepOutsidePlayMode_ReturnsNoOpWithGuidance() + { + // Verifies Step refuses to run outside PlayMode instead of silently queuing a frame step. + Assert.That(EditorApplication.isPlaying, Is.False); + ControlPlayModeUseCase useCase = new ControlPlayModeUseCase(); + ControlPlayModeSchema schema = new ControlPlayModeSchema + { + Action = PlayModeAction.Step, + }; + + ControlPlayModeResponse response = await useCase.ExecuteAsync(schema, CancellationToken.None); + + Assert.That(response.Message, Is.EqualTo("Play mode is not running. Step requires PlayMode; start it with --action Play first.")); + Assert.That(response.Changed, Is.False); + } + [Test] public async Task ExecuteAsync_WhenStopAlreadyStopped_ReturnsNoOpState() { diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md index 5dafca53b..74981cf37 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -54,17 +54,21 @@ Use `uloop pause-point-status --id state-transition-applied` only when you need ## Fast-Progressing Games -When the game advances on its own (a ball keeps bouncing, blocks keep falling), the state you are arranging can move past the marker before the input command and the wait are even issued. Freeze game time during setup, then restore it before triggering the marker: +When the game advances on its own (a ball keeps bouncing, blocks keep falling), the state you are arranging can move past the marker before the input command and the wait are even issued. Pause the Editor and walk frames explicitly instead: ```bash -# Freeze gameplay while arranging the scenario (input simulation still works) -uloop execute-dynamic-code --code "UnityEngine.Time.timeScale = 0f; return \"frozen\";" -# ... enable markers, position state, prepare assertions ... -uloop execute-dynamic-code --code "UnityEngine.Time.timeScale = 1f; return \"running\";" -# Now trigger the input and wait for the marker +# Freeze the whole player loop while arranging the scenario +uloop control-play-mode --action Pause +# ... enable markers, inspect/arrange state with execute-dynamic-code, get-hierarchy, get-logs ... +# Advance exactly one frame and stay paused (the Editor's Next Frame button) +uloop control-play-mode --action Step +# Resume right before sending the input you are verifying (input simulation needs an unpaused player) +uloop control-play-mode --action Play ``` -Use this only as a setup aid. A `Time.timeScale` change is never paused-frame proof by itself, and physics-driven transitions resume the moment the scale returns to 1, so trigger and wait immediately after restoring it. +Do not use `Time.timeScale = 0` for this: projects that read unscaled time keep advancing regardless, and the value silently persists into the next PlayMode session. Editor pause and `Step` freeze the entire player loop independent of `Time.timeScale`. + +A pause point hit leaves Unity in this same paused state, so `Step` also works right after a marker hits: inspect the paused frame, then step forward to watch the following frames commit one at a time. ## Marker Placement diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeSchema.cs b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeSchema.cs index b518f0e2b..e4cf769b6 100644 --- a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeSchema.cs +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeSchema.cs @@ -9,7 +9,8 @@ public enum PlayModeAction { Play = 0, Stop = 1, - Pause = 2 + Pause = 2, + Step = 3 } /// diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeUseCase.cs b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeUseCase.cs index ad00ff459..74ac4101e 100644 --- a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeUseCase.cs @@ -66,6 +66,19 @@ public Task ExecuteAsync(ControlPlayModeSchema paramete message = "Play mode paused"; break; + case PlayModeAction.Step: + // Same API as the Editor's Next Frame button: advances one frame and + // leaves the player paused, independent of Time.timeScale. + if (!wasPlaying) + { + message = "Play mode is not running. Step requires PlayMode; start it with --action Play first."; + break; + } + EditorApplication.Step(); + changed = true; + message = "Stepped one frame; play mode is paused."; + break; + default: message = $"Unknown action: {parameters.Action}"; break; diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/SKILL.md index 20f544bf2..e215f43e7 100644 --- a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/SKILL.md @@ -18,7 +18,7 @@ uloop control-play-mode [options] | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | string | `Play` | Action to perform: `Play`, `Stop`, `Pause` | +| `--action` | string | `Play` | Action to perform: `Play`, `Stop`, `Pause`, `Step` | | `--timeout-seconds` | integer | `180` | Maximum seconds to wait for the requested play mode state | ## Global Options @@ -41,6 +41,9 @@ uloop control-play-mode --action Stop # Pause play mode uloop control-play-mode --action Pause + +# Advance exactly one frame while paused (Next Frame button) +uloop control-play-mode --action Step ``` ## Output @@ -57,5 +60,6 @@ Returns JSON with the current play mode state: - Play action starts the game in the Unity Editor (also resumes from pause) - Stop action exits play mode and returns to edit mode. If Play Mode was already stopped, `Changed` is `false`, `WasAlreadyStopped` is `true`, and `Message` is `Play mode was already stopped`. - Pause action pauses the game while remaining in play mode +- Step action advances exactly one frame and leaves play mode paused (the Editor's Next Frame button; independent of Time.timeScale). Requires PlayMode to be running; repeat to walk transitions frame by frame - Useful for automated testing workflows - The command waits for the requested state before returning. Increase `--timeout-seconds` for projects with slow PlayMode entry. From fe0c918e6b0019076b830f969d9b218aafdea635 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 11 Jun 2026 19:12:21 +0900 Subject: [PATCH 26/31] Regenerate installed skill copies for the Step action --- .../skills/uloop-control-play-mode/SKILL.md | 6 +++++- .../skills/uloop-wait-for-pause-point/SKILL.md | 18 +++++++++++------- .../skills/uloop-control-play-mode/SKILL.md | 6 +++++- .../skills/uloop-wait-for-pause-point/SKILL.md | 18 +++++++++++------- 4 files changed, 32 insertions(+), 16 deletions(-) diff --git a/.agents/skills/uloop-control-play-mode/SKILL.md b/.agents/skills/uloop-control-play-mode/SKILL.md index 20f544bf2..e215f43e7 100644 --- a/.agents/skills/uloop-control-play-mode/SKILL.md +++ b/.agents/skills/uloop-control-play-mode/SKILL.md @@ -18,7 +18,7 @@ uloop control-play-mode [options] | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | string | `Play` | Action to perform: `Play`, `Stop`, `Pause` | +| `--action` | string | `Play` | Action to perform: `Play`, `Stop`, `Pause`, `Step` | | `--timeout-seconds` | integer | `180` | Maximum seconds to wait for the requested play mode state | ## Global Options @@ -41,6 +41,9 @@ uloop control-play-mode --action Stop # Pause play mode uloop control-play-mode --action Pause + +# Advance exactly one frame while paused (Next Frame button) +uloop control-play-mode --action Step ``` ## Output @@ -57,5 +60,6 @@ Returns JSON with the current play mode state: - Play action starts the game in the Unity Editor (also resumes from pause) - Stop action exits play mode and returns to edit mode. If Play Mode was already stopped, `Changed` is `false`, `WasAlreadyStopped` is `true`, and `Message` is `Play mode was already stopped`. - Pause action pauses the game while remaining in play mode +- Step action advances exactly one frame and leaves play mode paused (the Editor's Next Frame button; independent of Time.timeScale). Requires PlayMode to be running; repeat to walk transitions frame by frame - Useful for automated testing workflows - The command waits for the requested state before returning. Increase `--timeout-seconds` for projects with slow PlayMode entry. diff --git a/.agents/skills/uloop-wait-for-pause-point/SKILL.md b/.agents/skills/uloop-wait-for-pause-point/SKILL.md index 5dafca53b..74981cf37 100644 --- a/.agents/skills/uloop-wait-for-pause-point/SKILL.md +++ b/.agents/skills/uloop-wait-for-pause-point/SKILL.md @@ -54,17 +54,21 @@ Use `uloop pause-point-status --id state-transition-applied` only when you need ## Fast-Progressing Games -When the game advances on its own (a ball keeps bouncing, blocks keep falling), the state you are arranging can move past the marker before the input command and the wait are even issued. Freeze game time during setup, then restore it before triggering the marker: +When the game advances on its own (a ball keeps bouncing, blocks keep falling), the state you are arranging can move past the marker before the input command and the wait are even issued. Pause the Editor and walk frames explicitly instead: ```bash -# Freeze gameplay while arranging the scenario (input simulation still works) -uloop execute-dynamic-code --code "UnityEngine.Time.timeScale = 0f; return \"frozen\";" -# ... enable markers, position state, prepare assertions ... -uloop execute-dynamic-code --code "UnityEngine.Time.timeScale = 1f; return \"running\";" -# Now trigger the input and wait for the marker +# Freeze the whole player loop while arranging the scenario +uloop control-play-mode --action Pause +# ... enable markers, inspect/arrange state with execute-dynamic-code, get-hierarchy, get-logs ... +# Advance exactly one frame and stay paused (the Editor's Next Frame button) +uloop control-play-mode --action Step +# Resume right before sending the input you are verifying (input simulation needs an unpaused player) +uloop control-play-mode --action Play ``` -Use this only as a setup aid. A `Time.timeScale` change is never paused-frame proof by itself, and physics-driven transitions resume the moment the scale returns to 1, so trigger and wait immediately after restoring it. +Do not use `Time.timeScale = 0` for this: projects that read unscaled time keep advancing regardless, and the value silently persists into the next PlayMode session. Editor pause and `Step` freeze the entire player loop independent of `Time.timeScale`. + +A pause point hit leaves Unity in this same paused state, so `Step` also works right after a marker hits: inspect the paused frame, then step forward to watch the following frames commit one at a time. ## Marker Placement diff --git a/.claude/skills/uloop-control-play-mode/SKILL.md b/.claude/skills/uloop-control-play-mode/SKILL.md index 20f544bf2..e215f43e7 100644 --- a/.claude/skills/uloop-control-play-mode/SKILL.md +++ b/.claude/skills/uloop-control-play-mode/SKILL.md @@ -18,7 +18,7 @@ uloop control-play-mode [options] | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | string | `Play` | Action to perform: `Play`, `Stop`, `Pause` | +| `--action` | string | `Play` | Action to perform: `Play`, `Stop`, `Pause`, `Step` | | `--timeout-seconds` | integer | `180` | Maximum seconds to wait for the requested play mode state | ## Global Options @@ -41,6 +41,9 @@ uloop control-play-mode --action Stop # Pause play mode uloop control-play-mode --action Pause + +# Advance exactly one frame while paused (Next Frame button) +uloop control-play-mode --action Step ``` ## Output @@ -57,5 +60,6 @@ Returns JSON with the current play mode state: - Play action starts the game in the Unity Editor (also resumes from pause) - Stop action exits play mode and returns to edit mode. If Play Mode was already stopped, `Changed` is `false`, `WasAlreadyStopped` is `true`, and `Message` is `Play mode was already stopped`. - Pause action pauses the game while remaining in play mode +- Step action advances exactly one frame and leaves play mode paused (the Editor's Next Frame button; independent of Time.timeScale). Requires PlayMode to be running; repeat to walk transitions frame by frame - Useful for automated testing workflows - The command waits for the requested state before returning. Increase `--timeout-seconds` for projects with slow PlayMode entry. diff --git a/.claude/skills/uloop-wait-for-pause-point/SKILL.md b/.claude/skills/uloop-wait-for-pause-point/SKILL.md index 5dafca53b..74981cf37 100644 --- a/.claude/skills/uloop-wait-for-pause-point/SKILL.md +++ b/.claude/skills/uloop-wait-for-pause-point/SKILL.md @@ -54,17 +54,21 @@ Use `uloop pause-point-status --id state-transition-applied` only when you need ## Fast-Progressing Games -When the game advances on its own (a ball keeps bouncing, blocks keep falling), the state you are arranging can move past the marker before the input command and the wait are even issued. Freeze game time during setup, then restore it before triggering the marker: +When the game advances on its own (a ball keeps bouncing, blocks keep falling), the state you are arranging can move past the marker before the input command and the wait are even issued. Pause the Editor and walk frames explicitly instead: ```bash -# Freeze gameplay while arranging the scenario (input simulation still works) -uloop execute-dynamic-code --code "UnityEngine.Time.timeScale = 0f; return \"frozen\";" -# ... enable markers, position state, prepare assertions ... -uloop execute-dynamic-code --code "UnityEngine.Time.timeScale = 1f; return \"running\";" -# Now trigger the input and wait for the marker +# Freeze the whole player loop while arranging the scenario +uloop control-play-mode --action Pause +# ... enable markers, inspect/arrange state with execute-dynamic-code, get-hierarchy, get-logs ... +# Advance exactly one frame and stay paused (the Editor's Next Frame button) +uloop control-play-mode --action Step +# Resume right before sending the input you are verifying (input simulation needs an unpaused player) +uloop control-play-mode --action Play ``` -Use this only as a setup aid. A `Time.timeScale` change is never paused-frame proof by itself, and physics-driven transitions resume the moment the scale returns to 1, so trigger and wait immediately after restoring it. +Do not use `Time.timeScale = 0` for this: projects that read unscaled time keep advancing regardless, and the value silently persists into the next PlayMode session. Editor pause and `Step` freeze the entire player loop independent of `Time.timeScale`. + +A pause point hit leaves Unity in this same paused state, so `Step` also works right after a marker hits: inspect the paused frame, then step forward to watch the following frames commit one at a time. ## Marker Placement From 7bca19fd7aaf233d630678899c6d24bd4d62307c Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 11 Jun 2026 21:21:45 +0900 Subject: [PATCH 27/31] Release screenshot temporaries in finally blocks A failure during capture, blit, or pixel read previously leaked the temporary RenderTexture and left RenderTexture.active pointing at it. try/finally is the repository-approved pattern for resource release, so all three capture paths now restore the caller's active target and release the temporary even when the capture work throws. --- .../Screenshot/EditorWindowCaptureUtility.cs | 64 ++++++++++++------- 1 file changed, 40 insertions(+), 24 deletions(-) diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/EditorWindowCaptureUtility.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/EditorWindowCaptureUtility.cs index 6954016bd..892e0cfbe 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/EditorWindowCaptureUtility.cs +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/EditorWindowCaptureUtility.cs @@ -102,17 +102,22 @@ public static EditorWindow[] FindWindowsByName(string windowName, WindowMatchMod // would release a still-active render texture and emit a Console warning. RenderTexture previousActive = RenderTexture.active; RenderTexture rt = RenderTexture.GetTemporary(descriptor); + Texture2D texture; + try + { + InternalEditorUtilityBridge.CaptureEditorWindow(window, rt); - InternalEditorUtilityBridge.CaptureEditorWindow(window, rt); - - RenderTexture.active = rt; - - Texture2D texture = new(width, height, TextureFormat.RGB24, false); - texture.ReadPixels(new Rect(0, 0, width, height), 0, 0); - texture.Apply(); + RenderTexture.active = rt; - RenderTexture.active = previousActive; - RenderTexture.ReleaseTemporary(rt); + texture = new(width, height, TextureFormat.RGB24, false); + texture.ReadPixels(new Rect(0, 0, width, height), 0, 0); + texture.Apply(); + } + finally + { + RenderTexture.active = previousActive; + RenderTexture.ReleaseTemporary(rt); + } if (!Mathf.Approximately(resolutionScale, 1.0f)) { @@ -174,16 +179,22 @@ public static string[] GetOpenWindowNames() // temporary itself and releasing it would warn about an active render texture. RenderTexture previousActive = RenderTexture.active; RenderTexture flipped = RenderTexture.GetTemporary(flipDescriptor); - Graphics.Blit(rt, flipped, new Vector2(1f, -1f), new Vector2(0f, 1f)); - - RenderTexture.active = flipped; + Texture2D texture; + try + { + Graphics.Blit(rt, flipped, new Vector2(1f, -1f), new Vector2(0f, 1f)); - Texture2D texture = new(rt.width, rt.height, TextureFormat.RGB24, false); - texture.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0); - texture.Apply(); + RenderTexture.active = flipped; - RenderTexture.active = previousActive; - RenderTexture.ReleaseTemporary(flipped); + texture = new(rt.width, rt.height, TextureFormat.RGB24, false); + texture.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0); + texture.Apply(); + } + finally + { + RenderTexture.active = previousActive; + RenderTexture.ReleaseTemporary(flipped); + } if (!Mathf.Approximately(resolutionScale, 1.0f)) { @@ -205,14 +216,19 @@ private static Texture2D ApplyResolutionScaling(Texture2D originalTexture, float // is never released while active. RenderTexture previousActive = RenderTexture.active; RenderTexture rt = RenderTexture.GetTemporary(newWidth, newHeight); - Graphics.Blit(originalTexture, rt); - - RenderTexture.active = rt; - scaledTexture.ReadPixels(new Rect(0, 0, newWidth, newHeight), 0, 0); - scaledTexture.Apply(); - RenderTexture.active = previousActive; + try + { + Graphics.Blit(originalTexture, rt); - RenderTexture.ReleaseTemporary(rt); + RenderTexture.active = rt; + scaledTexture.ReadPixels(new Rect(0, 0, newWidth, newHeight), 0, 0); + scaledTexture.Apply(); + } + finally + { + RenderTexture.active = previousActive; + RenderTexture.ReleaseTemporary(rt); + } UnityEngine.Object.DestroyImmediate(originalTexture); return scaledTexture; From 495b3f6351588ac6db001925420aefdb602f5f38 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 11 Jun 2026 21:21:53 +0900 Subject: [PATCH 28/31] Stop busy masking from hiding dispatched RPC failures The expired-window busy masking applied to every final error, so a request that actually reached Unity and failed with a real RPC error could be overwritten by a stale server_busy seen earlier in the retry window. The mask now requires the final attempt to be undispatched, which matches its intent of explaining window-expiry transport errors. Also derive the compile wait timeout message from compileWaitTimeout instead of hardcoding 180000ms; the envelope test keeps the literal expectation as a regression pin. --- cli/internal/cli/connection_retry.go | 7 +- cli/internal/cli/connection_retry_test.go | 83 +++++++++++++++++++++++ cli/internal/cli/execution_errors.go | 10 ++- 3 files changed, 94 insertions(+), 6 deletions(-) diff --git a/cli/internal/cli/connection_retry.go b/cli/internal/cli/connection_retry.go index 4336de52e..4c4f6d17a 100644 --- a/cli/internal/cli/connection_retry.go +++ b/cli/internal/cli/connection_retry.go @@ -107,9 +107,10 @@ func sendWithTransientConnectionRetryAndResponseTimeout( continue } if !shouldRetryUndispatchedConnection(err, outcome) { - // A transport error caused by the expiring retry window must not mask a busy - // response seen earlier; busy is the truer diagnosis. - if err != nil && retryContext.Err() != nil && isUnityServerBusyRPCError(lastErr) { + // An undispatched transport error caused by the expiring retry window must not + // mask a busy response seen earlier; busy is the truer diagnosis. A dispatched + // failure is a real Unity answer and must surface as-is. + if err != nil && !outcome.RequestDispatched && retryContext.Err() != nil && isUnityServerBusyRPCError(lastErr) { return lastOutcome, lastErr } return outcome, err diff --git a/cli/internal/cli/connection_retry_test.go b/cli/internal/cli/connection_retry_test.go index 7dfebc048..df0750203 100644 --- a/cli/internal/cli/connection_retry_test.go +++ b/cli/internal/cli/connection_retry_test.go @@ -505,3 +505,86 @@ func TestSendWithTransientConnectionRetryReturnsBusyAfterRetryWindow(t *testing. t.Fatalf("busy must surface as the original RPC error, got: %v", err) } } + +// Verifies a dispatched RPC failure arriving after the retry window expires is not +// masked by a busy response seen earlier in the window. +func TestSendWithTransientConnectionRetrySurfacesDispatchedFailureAfterBusy(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("TCP endpoint injection is only used by this non-Windows client test") + } + + originalTimeout := serverConnectionRetryTimeout + originalPoll := serverConnectionRetryPoll + serverConnectionRetryTimeout = 40 * time.Millisecond + serverConnectionRetryPoll = 5 * time.Millisecond + t.Cleanup(func() { + serverConnectionRetryTimeout = originalTimeout + serverConnectionRetryPoll = originalPoll + }) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + defer func() { + _ = listener.Close() + }() + + busy := `{"jsonrpc":"2.0","id":1,"error":{"code":-32603,"message":"Unity is busy running 'compile'.","data":{"type":"server_busy","runningToolName":"compile","requestedToolName":"get-logs","message":"busy"}}}` + failure := `{"jsonrpc":"2.0","id":1,"error":{"code":-32603,"message":"tool execution failed","data":{"type":"tool_error","message":"tool execution failed"}}}` + go func() { + first := true + for { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + sendBusy := first + first = false + go func(conn net.Conn, sendBusy bool) { + defer func() { + _ = conn.Close() + }() + if _, readErr := unityipc.Read(bufio.NewReader(conn)); readErr != nil { + return + } + if sendBusy { + _ = unityipc.Write(conn, []byte(busy)) + return + } + accepted := `{"jsonrpc":"2.0","result":{"accepted":true},"uloop":{"phase":"accepted"},"id":1}` + if writeErr := unityipc.Write(conn, []byte(accepted)); writeErr != nil { + return + } + // Let the retry window expire while this request is already dispatched. + time.Sleep(80 * time.Millisecond) + _ = unityipc.Write(conn, []byte(failure)) + }(conn, sendBusy) + } + }() + + connection := unityipc.Connection{ + Endpoint: unityipc.Endpoint{ + Network: "tcp", + Address: listener.Addr().String(), + }, + ProjectRoot: t.TempDir(), + } + + _, err = sendWithTransientConnectionRetry( + context.Background(), + connection, + "get-logs", + map[string]any{}, + nil) + if err == nil { + t.Fatal("expected the dispatched failure to surface") + } + if isUnityServerBusyRPCError(err) { + t.Fatalf("dispatched failure must not be masked by the earlier busy response, got: %v", err) + } + var rpcErr *unityipc.RPCError + if !errors.As(err, &rpcErr) { + t.Fatalf("dispatched failure must surface as the original RPC error, got: %v", err) + } +} diff --git a/cli/internal/cli/execution_errors.go b/cli/internal/cli/execution_errors.go index 765b9f33f..cb535e645 100644 --- a/cli/internal/cli/execution_errors.go +++ b/cli/internal/cli/execution_errors.go @@ -1,10 +1,14 @@ package cli +import "fmt" + func compileWaitTimeoutError(projectRoot string) cliError { return cliError{ - ErrorCode: errorCodeCompileWaitTimeout, - Phase: errorPhaseCompileWaiting, - Message: "Compile status wait timed out after 180000ms. This does not mean the Unity Editor is frozen; the compile may simply still be running.", + ErrorCode: errorCodeCompileWaitTimeout, + Phase: errorPhaseCompileWaiting, + Message: fmt.Sprintf( + "Compile status wait timed out after %dms. This does not mean the Unity Editor is frozen; the compile may simply still be running.", + compileWaitTimeout.Milliseconds()), Retryable: true, SafeToRetry: true, ProjectRoot: projectRoot, From c2b02d17bf6139242e565863ca84499c4307e1cd Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 11 Jun 2026 21:22:02 +0900 Subject: [PATCH 29/31] Use the uloop command form consistently in simulate skill docs The pause point inspection sections mixed skill-style names (uloop-get-logs) with bare command names (get-logs), which invited copy/paste mistakes. All command references now use the runnable form (uloop get-logs), matching the examples sections. Installed copies regenerated. --- .agents/skills/uloop-simulate-keyboard/SKILL.md | 2 +- .agents/skills/uloop-simulate-mouse-input/SKILL.md | 2 +- .agents/skills/uloop-simulate-mouse-ui/SKILL.md | 8 ++++---- .claude/skills/uloop-simulate-keyboard/SKILL.md | 2 +- .claude/skills/uloop-simulate-mouse-input/SKILL.md | 2 +- .claude/skills/uloop-simulate-mouse-ui/SKILL.md | 8 ++++---- .../FirstPartyTools/SimulateKeyboard/Skill/SKILL.md | 2 +- .../FirstPartyTools/SimulateMouseInput/Skill/SKILL.md | 2 +- .../Editor/FirstPartyTools/SimulateMouseUi/Skill/SKILL.md | 8 ++++---- 9 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.agents/skills/uloop-simulate-keyboard/SKILL.md b/.agents/skills/uloop-simulate-keyboard/SKILL.md index e96064422..2ca050cb4 100644 --- a/.agents/skills/uloop-simulate-keyboard/SKILL.md +++ b/.agents/skills/uloop-simulate-keyboard/SKILL.md @@ -50,7 +50,7 @@ Use `KeyDown` / `KeyUp` when the scenario intentionally needs a held key. - Put the marker at a natural state transition after the app consumed the key, such as after a command is accepted, a state mutation is committed, an evaluation step resolves, or a dependent component is updated. Do not place it immediately after sending `simulate-keyboard`. - If the key handler has local variables, intermediate calculations, or branch reasons that `execute-dynamic-code` cannot inspect after the fact, log just those values near `UloopPausePoint.Pause("")` and read them with `uloop-get-logs` while Unity is paused. A pause point hit proves the line was reached, not the frame-local values. - Treat `simulate-keyboard Success=true`, generic action logs, and final durable counters as useful evidence, but not as paused-frame proof. -- If the response has `InterruptedByPausePoint: true`, Unity is paused for inspection and the tool released its held input bookkeeping. If a `UloopPausePoint.Pause` marker caused the pause, `PausePointId` and `PausePointHitCount` identify it. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. +- If the response has `InterruptedByPausePoint: true`, Unity is paused for inspection and the tool released its held input bookkeeping. If a `UloopPausePoint.Pause` marker caused the pause, `PausePointId` and `PausePointHitCount` identify it. Use `uloop get-logs`, `uloop get-hierarchy`, `uloop find-game-objects`, or `uloop execute-dynamic-code` before resuming. - Use distinct marker ids for strict phases, for example `input-read`, `state-updated`, and `result-committed`. ### KeyDown/KeyUp Rules diff --git a/.agents/skills/uloop-simulate-mouse-input/SKILL.md b/.agents/skills/uloop-simulate-mouse-input/SKILL.md index 6ee53578f..55bb8b634 100644 --- a/.agents/skills/uloop-simulate-mouse-input/SKILL.md +++ b/.agents/skills/uloop-simulate-mouse-input/SKILL.md @@ -53,7 +53,7 @@ uloop simulate-mouse-input --action [options] - Use `UloopPausePoint.Pause("")` with `uloop-wait-for-pause-point` as the standard frame proof when this input drives a state transition you are verifying. Final logs, screenshots, and durable state supplement the paused-frame check but do not replace it. - Place the pause point at a natural state transition after the app consumed the mouse input, such as after a command is accepted, a state mutation is committed, an evaluation step resolves, a tracked value changes, or a dependent component is updated. Do not place it immediately after sending `simulate-mouse-input`. - If the mouse handler has local variables, intermediate calculations, or branch reasons that `execute-dynamic-code` cannot inspect after the fact, log just those values near `UloopPausePoint.Pause("")` and read them with `uloop-get-logs` while Unity is paused. A pause point hit proves the line was reached, not the frame-local values. -- If the response has `InterruptedByPausePoint: true`, Unity is paused for inspection and the tool released its held input bookkeeping. `PausePointId` and `PausePointHitCount` identify the pause point that paused Unity. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. +- If the response has `InterruptedByPausePoint: true`, Unity is paused for inspection and the tool released its held input bookkeeping. `PausePointId` and `PausePointHitCount` identify the pause point that paused Unity. Use `uloop get-logs`, `uloop get-hierarchy`, `uloop find-game-objects`, or `uloop execute-dynamic-code` before resuming. - Use distinct ids for strict phases, for example `input-read`, `state-updated`, and `result-committed`. - Remove temporary pause-point/log instrumentation before final validation when it was added only for inspection. diff --git a/.agents/skills/uloop-simulate-mouse-ui/SKILL.md b/.agents/skills/uloop-simulate-mouse-ui/SKILL.md index 6d64aa1b1..a40030237 100644 --- a/.agents/skills/uloop-simulate-mouse-ui/SKILL.md +++ b/.agents/skills/uloop-simulate-mouse-ui/SKILL.md @@ -78,11 +78,11 @@ uloop simulate-mouse-ui --action --x --y [options] ## Pause Point Inspection (Standard for E2E) -- Use `UloopPausePoint.Pause("")` with `uloop-wait-for-pause-point` as the standard frame proof when this input drives a state transition you are verifying. Final logs, screenshots, and durable state supplement the paused-frame check but do not replace it. +- Use `UloopPausePoint.Pause("")` with `uloop wait-for-pause-point` as the standard frame proof when this input drives a state transition you are verifying. Final logs, screenshots, and durable state supplement the paused-frame check but do not replace it. - Place the pause point at a natural transition after the app consumed the UI event, such as after a command is accepted, a state mutation is committed, a tracked value changes, a UI/domain state syncs, or a success/failure/end condition is entered. -- If the UI handler has local variables, intermediate calculations, or branch reasons that `execute-dynamic-code` cannot inspect after the fact, log just those values near `UloopPausePoint.Pause("")` and read them with `uloop-get-logs` while Unity is paused. A pause point hit proves the line was reached, not the frame-local values. -- Treat `simulate-mouse-ui Success=true`, generic action logs, and final durable counters as useful evidence, not paused-frame proof. -- If a `UloopPausePoint.Pause` pauses Unity, inspect with `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. +- If the UI handler has local variables, intermediate calculations, or branch reasons that `uloop execute-dynamic-code` cannot inspect after the fact, log just those values near `UloopPausePoint.Pause("")` and read them with `uloop get-logs` while Unity is paused. A pause point hit proves the line was reached, not the frame-local values. +- Treat `uloop simulate-mouse-ui` `Success=true`, generic action logs, and final durable counters as useful evidence, not paused-frame proof. +- If a `UloopPausePoint.Pause` pauses Unity, inspect with `uloop get-logs`, `uloop get-hierarchy`, `uloop find-game-objects`, or `uloop execute-dynamic-code` before resuming. - Remove temporary pause-point/log instrumentation before final validation when it was added only for inspection. ## Examples diff --git a/.claude/skills/uloop-simulate-keyboard/SKILL.md b/.claude/skills/uloop-simulate-keyboard/SKILL.md index e96064422..2ca050cb4 100644 --- a/.claude/skills/uloop-simulate-keyboard/SKILL.md +++ b/.claude/skills/uloop-simulate-keyboard/SKILL.md @@ -50,7 +50,7 @@ Use `KeyDown` / `KeyUp` when the scenario intentionally needs a held key. - Put the marker at a natural state transition after the app consumed the key, such as after a command is accepted, a state mutation is committed, an evaluation step resolves, or a dependent component is updated. Do not place it immediately after sending `simulate-keyboard`. - If the key handler has local variables, intermediate calculations, or branch reasons that `execute-dynamic-code` cannot inspect after the fact, log just those values near `UloopPausePoint.Pause("")` and read them with `uloop-get-logs` while Unity is paused. A pause point hit proves the line was reached, not the frame-local values. - Treat `simulate-keyboard Success=true`, generic action logs, and final durable counters as useful evidence, but not as paused-frame proof. -- If the response has `InterruptedByPausePoint: true`, Unity is paused for inspection and the tool released its held input bookkeeping. If a `UloopPausePoint.Pause` marker caused the pause, `PausePointId` and `PausePointHitCount` identify it. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. +- If the response has `InterruptedByPausePoint: true`, Unity is paused for inspection and the tool released its held input bookkeeping. If a `UloopPausePoint.Pause` marker caused the pause, `PausePointId` and `PausePointHitCount` identify it. Use `uloop get-logs`, `uloop get-hierarchy`, `uloop find-game-objects`, or `uloop execute-dynamic-code` before resuming. - Use distinct marker ids for strict phases, for example `input-read`, `state-updated`, and `result-committed`. ### KeyDown/KeyUp Rules diff --git a/.claude/skills/uloop-simulate-mouse-input/SKILL.md b/.claude/skills/uloop-simulate-mouse-input/SKILL.md index 6ee53578f..55bb8b634 100644 --- a/.claude/skills/uloop-simulate-mouse-input/SKILL.md +++ b/.claude/skills/uloop-simulate-mouse-input/SKILL.md @@ -53,7 +53,7 @@ uloop simulate-mouse-input --action [options] - Use `UloopPausePoint.Pause("")` with `uloop-wait-for-pause-point` as the standard frame proof when this input drives a state transition you are verifying. Final logs, screenshots, and durable state supplement the paused-frame check but do not replace it. - Place the pause point at a natural state transition after the app consumed the mouse input, such as after a command is accepted, a state mutation is committed, an evaluation step resolves, a tracked value changes, or a dependent component is updated. Do not place it immediately after sending `simulate-mouse-input`. - If the mouse handler has local variables, intermediate calculations, or branch reasons that `execute-dynamic-code` cannot inspect after the fact, log just those values near `UloopPausePoint.Pause("")` and read them with `uloop-get-logs` while Unity is paused. A pause point hit proves the line was reached, not the frame-local values. -- If the response has `InterruptedByPausePoint: true`, Unity is paused for inspection and the tool released its held input bookkeeping. `PausePointId` and `PausePointHitCount` identify the pause point that paused Unity. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. +- If the response has `InterruptedByPausePoint: true`, Unity is paused for inspection and the tool released its held input bookkeeping. `PausePointId` and `PausePointHitCount` identify the pause point that paused Unity. Use `uloop get-logs`, `uloop get-hierarchy`, `uloop find-game-objects`, or `uloop execute-dynamic-code` before resuming. - Use distinct ids for strict phases, for example `input-read`, `state-updated`, and `result-committed`. - Remove temporary pause-point/log instrumentation before final validation when it was added only for inspection. diff --git a/.claude/skills/uloop-simulate-mouse-ui/SKILL.md b/.claude/skills/uloop-simulate-mouse-ui/SKILL.md index 6d64aa1b1..a40030237 100644 --- a/.claude/skills/uloop-simulate-mouse-ui/SKILL.md +++ b/.claude/skills/uloop-simulate-mouse-ui/SKILL.md @@ -78,11 +78,11 @@ uloop simulate-mouse-ui --action --x --y [options] ## Pause Point Inspection (Standard for E2E) -- Use `UloopPausePoint.Pause("")` with `uloop-wait-for-pause-point` as the standard frame proof when this input drives a state transition you are verifying. Final logs, screenshots, and durable state supplement the paused-frame check but do not replace it. +- Use `UloopPausePoint.Pause("")` with `uloop wait-for-pause-point` as the standard frame proof when this input drives a state transition you are verifying. Final logs, screenshots, and durable state supplement the paused-frame check but do not replace it. - Place the pause point at a natural transition after the app consumed the UI event, such as after a command is accepted, a state mutation is committed, a tracked value changes, a UI/domain state syncs, or a success/failure/end condition is entered. -- If the UI handler has local variables, intermediate calculations, or branch reasons that `execute-dynamic-code` cannot inspect after the fact, log just those values near `UloopPausePoint.Pause("")` and read them with `uloop-get-logs` while Unity is paused. A pause point hit proves the line was reached, not the frame-local values. -- Treat `simulate-mouse-ui Success=true`, generic action logs, and final durable counters as useful evidence, not paused-frame proof. -- If a `UloopPausePoint.Pause` pauses Unity, inspect with `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. +- If the UI handler has local variables, intermediate calculations, or branch reasons that `uloop execute-dynamic-code` cannot inspect after the fact, log just those values near `UloopPausePoint.Pause("")` and read them with `uloop get-logs` while Unity is paused. A pause point hit proves the line was reached, not the frame-local values. +- Treat `uloop simulate-mouse-ui` `Success=true`, generic action logs, and final durable counters as useful evidence, not paused-frame proof. +- If a `UloopPausePoint.Pause` pauses Unity, inspect with `uloop get-logs`, `uloop get-hierarchy`, `uloop find-game-objects`, or `uloop execute-dynamic-code` before resuming. - Remove temporary pause-point/log instrumentation before final validation when it was added only for inspection. ## Examples diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md index e96064422..2ca050cb4 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md @@ -50,7 +50,7 @@ Use `KeyDown` / `KeyUp` when the scenario intentionally needs a held key. - Put the marker at a natural state transition after the app consumed the key, such as after a command is accepted, a state mutation is committed, an evaluation step resolves, or a dependent component is updated. Do not place it immediately after sending `simulate-keyboard`. - If the key handler has local variables, intermediate calculations, or branch reasons that `execute-dynamic-code` cannot inspect after the fact, log just those values near `UloopPausePoint.Pause("")` and read them with `uloop-get-logs` while Unity is paused. A pause point hit proves the line was reached, not the frame-local values. - Treat `simulate-keyboard Success=true`, generic action logs, and final durable counters as useful evidence, but not as paused-frame proof. -- If the response has `InterruptedByPausePoint: true`, Unity is paused for inspection and the tool released its held input bookkeeping. If a `UloopPausePoint.Pause` marker caused the pause, `PausePointId` and `PausePointHitCount` identify it. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. +- If the response has `InterruptedByPausePoint: true`, Unity is paused for inspection and the tool released its held input bookkeeping. If a `UloopPausePoint.Pause` marker caused the pause, `PausePointId` and `PausePointHitCount` identify it. Use `uloop get-logs`, `uloop get-hierarchy`, `uloop find-game-objects`, or `uloop execute-dynamic-code` before resuming. - Use distinct marker ids for strict phases, for example `input-read`, `state-updated`, and `result-committed`. ### KeyDown/KeyUp Rules diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md index 6ee53578f..55bb8b634 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md @@ -53,7 +53,7 @@ uloop simulate-mouse-input --action [options] - Use `UloopPausePoint.Pause("")` with `uloop-wait-for-pause-point` as the standard frame proof when this input drives a state transition you are verifying. Final logs, screenshots, and durable state supplement the paused-frame check but do not replace it. - Place the pause point at a natural state transition after the app consumed the mouse input, such as after a command is accepted, a state mutation is committed, an evaluation step resolves, a tracked value changes, or a dependent component is updated. Do not place it immediately after sending `simulate-mouse-input`. - If the mouse handler has local variables, intermediate calculations, or branch reasons that `execute-dynamic-code` cannot inspect after the fact, log just those values near `UloopPausePoint.Pause("")` and read them with `uloop-get-logs` while Unity is paused. A pause point hit proves the line was reached, not the frame-local values. -- If the response has `InterruptedByPausePoint: true`, Unity is paused for inspection and the tool released its held input bookkeeping. `PausePointId` and `PausePointHitCount` identify the pause point that paused Unity. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. +- If the response has `InterruptedByPausePoint: true`, Unity is paused for inspection and the tool released its held input bookkeeping. `PausePointId` and `PausePointHitCount` identify the pause point that paused Unity. Use `uloop get-logs`, `uloop get-hierarchy`, `uloop find-game-objects`, or `uloop execute-dynamic-code` before resuming. - Use distinct ids for strict phases, for example `input-read`, `state-updated`, and `result-committed`. - Remove temporary pause-point/log instrumentation before final validation when it was added only for inspection. diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/Skill/SKILL.md index 6d64aa1b1..a40030237 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/Skill/SKILL.md @@ -78,11 +78,11 @@ uloop simulate-mouse-ui --action --x --y [options] ## Pause Point Inspection (Standard for E2E) -- Use `UloopPausePoint.Pause("")` with `uloop-wait-for-pause-point` as the standard frame proof when this input drives a state transition you are verifying. Final logs, screenshots, and durable state supplement the paused-frame check but do not replace it. +- Use `UloopPausePoint.Pause("")` with `uloop wait-for-pause-point` as the standard frame proof when this input drives a state transition you are verifying. Final logs, screenshots, and durable state supplement the paused-frame check but do not replace it. - Place the pause point at a natural transition after the app consumed the UI event, such as after a command is accepted, a state mutation is committed, a tracked value changes, a UI/domain state syncs, or a success/failure/end condition is entered. -- If the UI handler has local variables, intermediate calculations, or branch reasons that `execute-dynamic-code` cannot inspect after the fact, log just those values near `UloopPausePoint.Pause("")` and read them with `uloop-get-logs` while Unity is paused. A pause point hit proves the line was reached, not the frame-local values. -- Treat `simulate-mouse-ui Success=true`, generic action logs, and final durable counters as useful evidence, not paused-frame proof. -- If a `UloopPausePoint.Pause` pauses Unity, inspect with `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. +- If the UI handler has local variables, intermediate calculations, or branch reasons that `uloop execute-dynamic-code` cannot inspect after the fact, log just those values near `UloopPausePoint.Pause("")` and read them with `uloop get-logs` while Unity is paused. A pause point hit proves the line was reached, not the frame-local values. +- Treat `uloop simulate-mouse-ui` `Success=true`, generic action logs, and final durable counters as useful evidence, not paused-frame proof. +- If a `UloopPausePoint.Pause` pauses Unity, inspect with `uloop get-logs`, `uloop get-hierarchy`, `uloop find-game-objects`, or `uloop execute-dynamic-code` before resuming. - Remove temporary pause-point/log instrumentation before final validation when it was added only for inspection. ## Examples From f4d3c187c4d80fbb9eecc2faba1a04ee741dc498 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 11 Jun 2026 21:33:06 +0900 Subject: [PATCH 30/31] Remove timing race from the dispatched-failure retry test The 40ms retry window left only ~30ms for the second request to dial and receive its accepted ack, which could flake under CI scheduler jitter. The window is now 150ms, and the failure response is held for twice the window starting only after the accepted ack is written, so the window has always expired when the failure arrives. --- cli/internal/cli/connection_retry_test.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/cli/internal/cli/connection_retry_test.go b/cli/internal/cli/connection_retry_test.go index df0750203..8aaa06404 100644 --- a/cli/internal/cli/connection_retry_test.go +++ b/cli/internal/cli/connection_retry_test.go @@ -515,7 +515,8 @@ func TestSendWithTransientConnectionRetrySurfacesDispatchedFailureAfterBusy(t *t originalTimeout := serverConnectionRetryTimeout originalPoll := serverConnectionRetryPoll - serverConnectionRetryTimeout = 40 * time.Millisecond + retryWindow := 150 * time.Millisecond + serverConnectionRetryTimeout = retryWindow serverConnectionRetryPoll = 5 * time.Millisecond t.Cleanup(func() { serverConnectionRetryTimeout = originalTimeout @@ -556,8 +557,10 @@ func TestSendWithTransientConnectionRetrySurfacesDispatchedFailureAfterBusy(t *t if writeErr := unityipc.Write(conn, []byte(accepted)); writeErr != nil { return } - // Let the retry window expire while this request is already dispatched. - time.Sleep(80 * time.Millisecond) + // The delay starts only after the accepted ack is on the wire, and twice + // the retry window guarantees the window has expired before the failure + // response arrives, regardless of scheduler jitter. + time.Sleep(retryWindow * 2) _ = unityipc.Write(conn, []byte(failure)) }(conn, sendBusy) } From 5ebc51b9386c34db8ca4ed2e22164b94d408f243 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 11 Jun 2026 21:47:02 +0900 Subject: [PATCH 31/31] Make busy masking immune to connection-deadline timer races CI exposed a race in the expired-window busy masking: the connection read/write deadline shares its wall-clock instant with the retry context, and the i/o timeout can fire microseconds before the context reports expiry, so the raw transport error leaked through both the terminal branch and the no-process probe branch. The mask no longer compares against the window deadline: after a busy response in the window, any terminal non-RPC transport error reports busy (parent cancellation still wins). Real RPC answers are recognized with errors.As instead of the dispatch flag, which also fixes the dispatched read-timeout case the flag-based guard regressed. --- cli/internal/cli/connection_retry.go | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/cli/internal/cli/connection_retry.go b/cli/internal/cli/connection_retry.go index 4c4f6d17a..d791c1566 100644 --- a/cli/internal/cli/connection_retry.go +++ b/cli/internal/cli/connection_retry.go @@ -107,10 +107,16 @@ func sendWithTransientConnectionRetryAndResponseTimeout( continue } if !shouldRetryUndispatchedConnection(err, outcome) { - // An undispatched transport error caused by the expiring retry window must not - // mask a busy response seen earlier; busy is the truer diagnosis. A dispatched - // failure is a real Unity answer and must surface as-is. - if err != nil && !outcome.RequestDispatched && retryContext.Err() != nil && isUnityServerBusyRPCError(lastErr) { + // A transport error after a busy response in this window must not mask the + // busy; the server answered moments ago, so busy is the truer diagnosis. + // An RPC error is a real Unity answer, not a transport artifact, and must + // surface as-is. The transport error is not compared against the window + // deadline because the connection deadline can fire microseconds before + // the context reports expiry. + if err != nil && !isRPCError(err) && isUnityServerBusyRPCError(lastErr) { + if ctx.Err() != nil { + return outcome, ctx.Err() + } return lastOutcome, lastErr } return outcome, err @@ -136,6 +142,12 @@ func sendWithTransientConnectionRetryAndResponseTimeout( return outcome, processErr } if runningProcess == nil { + // Same masking as the probe-error path: a busy response seen during the + // window proves a server answered moments ago, so it is a truer diagnosis + // than a final dial cut short by the expiring retry context. + if retryContext.Err() != nil && isUnityServerBusyRPCError(lastErr) { + return lastOutcome, lastErr + } return outcome, err } if !focusAttempted { @@ -168,6 +180,13 @@ func sendWithTransientConnectionRetryAndResponseTimeout( } } +// Reports whether the error is a real RPC answer from Unity rather than a +// transport-level failure such as a dial or read timeout. +func isRPCError(err error) bool { + var rpcErr *unityipc.RPCError + return errors.As(err, &rpcErr) +} + func isUnityServerBusyRPCError(err error) bool { var rpcErr *unityipc.RPCError if !errors.As(err, &rpcErr) {