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-execute-dynamic-code/SKILL.md b/.agents/skills/uloop-execute-dynamic-code/SKILL.md index d29fcdc9c..ee44f5d45 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 @@ -21,8 +23,9 @@ For basic selected GameObject discovery or property inspection, use `find-game-o ## 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-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-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 fc6596887..2ca050cb4 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 `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 @@ -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,11 @@ 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 +- `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 78b3f0fa8..55bb8b634 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 `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. ### 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,9 @@ 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("")` +- `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 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..a40030237 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 `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 ```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..74981cf37 --- /dev/null +++ b/.agents/skills/uloop-wait-for-pause-point/SKILL.md @@ -0,0 +1,86 @@ +--- +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. 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 +``` + +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. + +## 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. 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. + +## 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. Pause the Editor and walk frames explicitly instead: + +```bash +# 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 +``` + +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 + +- 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-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-execute-dynamic-code/SKILL.md b/.claude/skills/uloop-execute-dynamic-code/SKILL.md index d29fcdc9c..ee44f5d45 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 @@ -21,8 +23,9 @@ For basic selected GameObject discovery or property inspection, use `find-game-o ## 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-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-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 fc6596887..2ca050cb4 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 `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 @@ -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,11 @@ 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 +- `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 78b3f0fa8..55bb8b634 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 `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. ### 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,9 @@ 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("")` +- `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 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..a40030237 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 `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 ```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..74981cf37 --- /dev/null +++ b/.claude/skills/uloop-wait-for-pause-point/SKILL.md @@ -0,0 +1,86 @@ +--- +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. 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 +``` + +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. + +## 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. 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. + +## 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. Pause the Editor and walk frames explicitly instead: + +```bash +# 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 +``` + +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 + +- 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/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/Assets/Tests/Editor/PausePointTests.cs b/Assets/Tests/Editor/PausePointTests.cs index 9d10457e3..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; @@ -41,10 +42,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 +55,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 +72,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); @@ -84,6 +85,34 @@ public void Break_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() { @@ -92,7 +121,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,19 +147,71 @@ 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); 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() { // 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 +223,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 +235,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 +255,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 +268,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..db4890ca8 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() { @@ -118,9 +165,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 +188,23 @@ 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.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."); } [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,19 +225,60 @@ 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.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."); } + [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/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~/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/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md index fec283c84..74981cf37 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,25 +14,26 @@ 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`. -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 ``` -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. +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. ## When To Use @@ -47,9 +48,27 @@ 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. 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. + +## 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. Pause the Editor and walk frames explicitly instead: + +```bash +# 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 +``` + +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`. -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. +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 @@ -61,7 +80,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..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. /// @@ -95,9 +105,11 @@ 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; } + public List? PausePointHits { get; set; } + public bool? PressEdgeObserved { get; set; } } /// @@ -127,9 +139,10 @@ 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; } + public List? PausePointHits { get; set; } } /// 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. diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/SKILL.md index bfd3f4e0a..ee44f5d45 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 @@ -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/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..4409f1c3e 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,20 @@ internal static PausePointResponse FromClearAll(UloopPausePointClearAllResult re { Status = UloopPausePointStatus.Cleared, ClearedCount = result.ClearedCount, - Message = "Debug breaks cleared." + Message = result.ClearedCount == 0 + ? "No active pause points to clear." + : "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 +104,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 +121,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 +184,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/Screenshot/EditorWindowCaptureUtility.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/EditorWindowCaptureUtility.cs index df290bce9..892e0cfbe 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/EditorWindowCaptureUtility.cs +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/EditorWindowCaptureUtility.cs @@ -97,19 +97,27 @@ public static EditorWindow[] FindWindowsByName(string windowName, WindowMatchMod { descriptor.sRGB = false; } - RenderTexture rt = RenderTexture.GetTemporary(descriptor); - - InternalEditorUtilityBridge.CaptureEditorWindow(window, rt); - + // 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.active = rt; + RenderTexture rt = RenderTexture.GetTemporary(descriptor); + Texture2D texture; + try + { + InternalEditorUtilityBridge.CaptureEditorWindow(window, 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)) { @@ -166,18 +174,27 @@ public static string[] GetOpenWindowNames() { flipDescriptor.sRGB = false; } - RenderTexture flipped = RenderTexture.GetTemporary(flipDescriptor); - Graphics.Blit(rt, flipped, new Vector2(1f, -1f), new Vector2(0f, 1f)); - + // 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.active = flipped; + RenderTexture flipped = RenderTexture.GetTemporary(flipDescriptor); + 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)) { @@ -194,15 +211,24 @@ 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; + 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; diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs index 6d5227847..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 @@ -13,9 +15,11 @@ 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 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 b4420e101..9c5f04175 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardTool.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardTool.cs @@ -48,9 +48,11 @@ 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, + 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 39e8c6327..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; @@ -171,8 +172,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 +196,8 @@ private async Task ExecutePress( } finally { + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); + InputSystem.onAfterUpdate -= pressEdgeMonitor; if (waitOutcome == InputSimulationWaitOutcome.TimedOut) { ScheduleTimedOutPressCleanup(keyboard, key, pressWasApplied); @@ -220,7 +231,7 @@ private async Task ExecutePress( if (waitOutcome == InputSimulationWaitOutcome.Paused) { - return InterruptedPressResult(keyName); + return InterruptedKeyResult(UnityCliLoopKeyboardAction.Press, keyName, pressEdgeObserved); } if (waitOutcome == InputSimulationWaitOutcome.TimedOut) @@ -229,12 +240,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 +270,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 +295,8 @@ private async Task ExecuteKeyDown(Keyboard } finally { + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); + InputSystem.onAfterUpdate -= keyDownEdgeMonitor; if (waitOutcome == InputSimulationWaitOutcome.TimedOut) { ScheduleTimedOutHeldKeyCleanup(keyboard, key, keyName, keyDownApplied); @@ -292,7 +314,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) @@ -300,12 +322,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 }; } @@ -341,7 +367,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) @@ -367,24 +393,24 @@ 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() { 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, + PressEdgeObserved = pressEdgeObserved }; - AttachDebugBreakHit(result); + AttachPausePointHit(result); return result; } @@ -401,7 +427,7 @@ private static UnityCliLoopKeyboardSimulationResult TimedOutKeyResult( }; } - private static void AttachDebugBreakHit(UnityCliLoopKeyboardSimulationResult result) + private static void AttachPausePointHit(UnityCliLoopKeyboardSimulationResult result) { if (result == null) { @@ -426,8 +452,29 @@ private static void AttachDebugBreakHit(UnityCliLoopKeyboardSimulationResult res return; } - result.DebugBreakId = snapshotId; - result.DebugBreakHitCount = snapshot.HitCount; + 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) @@ -558,6 +605,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 5fbd9d745..2ca050cb4 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 `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 @@ -94,9 +94,11 @@ 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 +- `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 673a755e8..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 @@ -15,9 +17,10 @@ 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 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 d97a39a1e..9b5840794 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputTool.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputTool.cs @@ -57,9 +57,10 @@ 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, + PausePointHits = result.PausePointHits, }; } } diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs index 00f31d428..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; @@ -554,11 +555,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 +586,7 @@ private static UnityCliLoopMouseInputSimulationResult TimedOutActionResult( }; } - private static void AttachDebugBreakHit(UnityCliLoopMouseInputSimulationResult result) + private static void AttachPausePointHit(UnityCliLoopMouseInputSimulationResult result) { if (result == null) { @@ -610,8 +611,29 @@ private static void AttachDebugBreakHit(UnityCliLoopMouseInputSimulationResult r return; } - result.DebugBreakId = snapshotId; - result.DebugBreakHitCount = snapshot.HitCount; + 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 8b1f12c81..55bb8b634 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 `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 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,9 @@ 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("")` +- `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 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..a40030237 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. -- 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. +- 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 `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/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..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) { @@ -41,7 +44,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); } @@ -105,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; @@ -116,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; @@ -146,6 +167,7 @@ public static void ResetForTests() { Entries.Clear(); _latestHitSnapshot = null; + _hitSnapshots.Clear(); _pauseController = new UnityEditorPausePointPauseController(); _nowProvider = () => DateTime.UtcNow; } @@ -235,7 +257,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 +289,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 +318,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() + public void MarkCleared(string message = "Pause point cleared.") { IsEnabled = false; Status = UloopPausePointStatus.Cleared; - Message = "Debug break cleared."; + Message = message; } public void RecordHit(DateTime nowUtc, bool isPlaying, bool isPaused) @@ -314,7 +336,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/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/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..e243839c6 100644 --- a/cli/internal/cli/completion_options.go +++ b/cli/internal/cli/completion_options.go @@ -12,13 +12,14 @@ var nativeCommandOptions = map[string][]string{ }, installCommandName: {"--" + installDirFlagName}, updateCommandName: {"--" + updateToVersionFlagName}, - debugBreakWaitCommandName: { - "--" + debugBreakIDFlagName, - "--" + debugBreakTimeoutFlagName, + pausePointWaitCommandName: { + "--" + pausePointIDFlagName, + "--" + pausePointTimeoutFlagName, + "--" + pausePointLogsMaxCountFlagName, "--" + projectPathFlagName, }, - debugBreakStatusUserCommandName: { - "--" + debugBreakIDFlagName, + pausePointStatusUserCommandName: { + "--" + pausePointIDFlagName, "--" + projectPathFlagName, }, } diff --git a/cli/internal/cli/connection_retry.go b/cli/internal/cli/connection_retry.go index 5040098ce..d791c1566 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,7 +91,34 @@ 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) { + // 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 } @@ -100,6 +128,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, @@ -109,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 { @@ -141,6 +180,25 @@ 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) { + 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..8aaa06404 100644 --- a/cli/internal/cli/connection_retry_test.go +++ b/cli/internal/cli/connection_retry_test.go @@ -379,3 +379,215 @@ 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 + } + // 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)) + }() + } + }() + + 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) + } +} + +// 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 + retryWindow := 150 * time.Millisecond + serverConnectionRetryTimeout = retryWindow + 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 + } + // 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) + } + }() + + 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/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/debug_break_wait_test.go b/cli/internal/cli/debug_break_wait_test.go deleted file mode 100644 index a690ec5d5..000000000 --- a/cli/internal/cli/debug_break_wait_test.go +++ /dev/null @@ -1,397 +0,0 @@ -package cli - -import ( - "bytes" - "context" - "encoding/json" - "path/filepath" - "strings" - "testing" - "time" - - "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 - defer func() { - queryDebugBreakStatus = originalQuery - debugBreakStatusPoll = originalPoll - }() - - responses := []debugBreakStatusResponse{ - {Id: "jump", Status: debugBreakStatusEnabled, IsEnabled: true}, - {Id: "jump", Status: debugBreakStatusHit, IsHit: true, IsPaused: true, HitCount: 1}, - } - requestCount := 0 - queryDebugBreakStatus = func( - ctx context.Context, - connection unityipc.Connection, - id string, - ) (debugBreakStatusResponse, error) { - if id != "jump" { - t.Fatalf("id mismatch: %s", id) - } - response := responses[requestCount] - requestCount++ - return response, nil - } - - response, state, err := waitForDebugBreak(context.Background(), unityipc.Connection{}, waitForDebugBreakOptions{ - id: "jump", - timeoutSeconds: 1, - timeout: time.Second, - }) - if err != nil { - t.Fatalf("waitForDebugBreak failed: %v", err) - } - if state != debugBreakWaitStateHit { - t.Fatalf("state mismatch: %s", state) - } - if response.Status != debugBreakStatusHit || response.HitCount != 1 { - t.Fatalf("response mismatch: %#v", response) - } - if requestCount != 2 { - t.Fatalf("request count mismatch: %d", requestCount) - } -} - -// 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 - defer func() { - queryDebugBreakStatus = originalQuery - clearDebugBreakStatus = originalClear - debugBreakStatusPoll = originalPoll - }() - - queryDebugBreakStatus = func( - ctx context.Context, - connection unityipc.Connection, - id string, - ) (debugBreakStatusResponse, error) { - return debugBreakStatusResponse{ - Id: id, - Status: debugBreakStatusEnabled, - IsEnabled: true, - TimeoutSeconds: 1, - ElapsedSinceEnabledMilliseconds: 100, - IsPlaying: true, - IsPaused: false, - Message: "Debug break enabled.", - }, nil - } - - clearedID := "" - clearDebugBreakStatus = func( - ctx context.Context, - connection unityipc.Connection, - id string, - ) (debugBreakStatusResponse, error) { - clearedID = id - return debugBreakStatusResponse{Id: id, Status: debugBreakStatusCleared}, nil - } - - var stdout bytes.Buffer - var stderr bytes.Buffer - code := runWaitForDebugBreak(context.Background(), unityipc.Connection{}, waitForDebugBreakOptions{ - id: "jump", - timeoutSeconds: 1, - timeout: 5 * time.Millisecond, - }, &stdout, &stderr) - - if code != 1 { - t.Fatalf("expected failure, got %d with stdout %s", code, stdout.String()) - } - if clearedID != "jump" { - t.Fatalf("cleared id mismatch: %s", clearedID) - } - if !strings.Contains(stderr.String(), errorCodeDebugBreakWaitTimeout) { - t.Fatalf("timeout error missing from stderr: %s", stderr.String()) - } - envelope := parseDebugBreakErrorEnvelope(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." { - t.Fatalf("markerMessage detail mismatch: %#v", envelope.Error.Details) - } - if envelope.Error.Details["elapsedSinceEnabledMilliseconds"] != float64(100) { - t.Fatalf("elapsedSinceEnabledMilliseconds detail mismatch: %#v", envelope.Error.Details) - } - if envelope.Error.Details["remainingMilliseconds"] != float64(900) { - t.Fatalf("remainingMilliseconds detail mismatch: %#v", envelope.Error.Details) - } -} - -// Verifies wait-for-debug-break does one final status probe before treating timeout as missed. -func TestRunWaitForDebugBreakReturnsFinalHitAtTimeoutBoundary(t *testing.T) { - originalQuery := queryDebugBreakStatus - originalClear := clearDebugBreakStatus - defer func() { - queryDebugBreakStatus = originalQuery - clearDebugBreakStatus = originalClear - }() - - requestCount := 0 - queryDebugBreakStatus = func( - ctx context.Context, - connection unityipc.Connection, - id string, - ) (debugBreakStatusResponse, error) { - requestCount++ - if requestCount == 1 { - return debugBreakStatusResponse{ - Id: id, - Status: debugBreakStatusEnabled, - IsEnabled: true, - }, nil - } - return debugBreakStatusResponse{ - Id: id, - Status: debugBreakStatusHit, - IsHit: true, - IsPaused: true, - HitCount: 1, - }, nil - } - - clearedID := "" - clearDebugBreakStatus = func( - ctx context.Context, - connection unityipc.Connection, - id string, - ) (debugBreakStatusResponse, error) { - clearedID = id - return debugBreakStatusResponse{Id: id, Status: debugBreakStatusCleared}, nil - } - - var stdout bytes.Buffer - var stderr bytes.Buffer - code := runWaitForDebugBreak(context.Background(), unityipc.Connection{}, waitForDebugBreakOptions{ - id: "jump", - timeoutSeconds: 1, - timeout: 5 * time.Millisecond, - }, &stdout, &stderr) - - if code != 0 { - t.Fatalf("expected success, got %d with stderr %s", code, stderr.String()) - } - if clearedID != "" { - t.Fatalf("marker should not be cleared after final hit: %s", clearedID) - } - var response debugBreakStatusResponse - 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 { - t.Fatalf("response mismatch: %#v", response) - } - if requestCount != 2 { - t.Fatalf("request count mismatch: %d", requestCount) - } -} - -// Verifies wait-for-debug-break rejects calls before the marker is enabled. -func TestWaitForDebugBreakReturnsNotEnabledStateImmediately(t *testing.T) { - originalQuery := queryDebugBreakStatus - defer func() { - queryDebugBreakStatus = originalQuery - }() - - queryDebugBreakStatus = func( - ctx context.Context, - connection unityipc.Connection, - id string, - ) (debugBreakStatusResponse, error) { - return debugBreakStatusResponse{Id: id, Status: debugBreakStatusNotEnabled, IsPlaying: true}, nil - } - - response, state, err := waitForDebugBreak(context.Background(), unityipc.Connection{}, waitForDebugBreakOptions{ - id: "jump", - timeoutSeconds: 1, - timeout: time.Second, - }) - if err != nil { - t.Fatalf("waitForDebugBreak failed: %v", err) - } - if state != debugBreakWaitStateNotEnabled { - t.Fatalf("state mismatch: %s", state) - } - if response.Status != debugBreakStatusNotEnabled { - t.Fatalf("response mismatch: %#v", response) - } -} - -// Verifies not-enabled failures use the user-facing enabled terminology. -func TestRunWaitForDebugBreakReportsNotEnabledError(t *testing.T) { - originalQuery := queryDebugBreakStatus - defer func() { - queryDebugBreakStatus = originalQuery - }() - - queryDebugBreakStatus = func( - ctx context.Context, - connection unityipc.Connection, - id string, - ) (debugBreakStatusResponse, error) { - return debugBreakStatusResponse{ - Id: id, - Status: debugBreakStatusNotEnabled, - IsPlaying: true, - IsPaused: false, - Message: "Debug break is not enabled.", - }, nil - } - - var stdout bytes.Buffer - var stderr bytes.Buffer - code := runWaitForDebugBreak(context.Background(), unityipc.Connection{}, waitForDebugBreakOptions{ - id: "jump", - timeoutSeconds: 1, - timeout: time.Second, - }, &stdout, &stderr) - - 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 { - t.Fatalf("error code mismatch: %#v", envelope.Error) - } - if envelope.Error.Details["status"] != debugBreakStatusNotEnabled { - t.Fatalf("status detail mismatch: %#v", envelope.Error.Details) - } - if envelope.Error.Details["isPlaying"] != true || envelope.Error.Details["isPaused"] != false { - t.Fatalf("play state details mismatch: %#v", envelope.Error.Details) - } -} - -// Verifies expired markers report no remaining enabled lifetime. -func TestDebugBreakExpiredErrorReportsNoRemainingTime(t *testing.T) { - response := debugBreakStatusResponse{ - Id: "jump", - Status: debugBreakStatusExpired, - TimeoutSeconds: 1, - ElapsedSinceEnabledMilliseconds: 1200, - IsPlaying: true, - Message: "Debug break expired before it was hit.", - } - - cliErr := debugBreakWaitError("/tmp/MyProject", waitForDebugBreakOptions{ - id: "jump", - timeoutSeconds: 1, - }, response, debugBreakWaitStateExpired) - - if cliErr.ErrorCode != errorCodeDebugBreakExpired { - t.Fatalf("error code mismatch: %#v", cliErr) - } - if cliErr.Details["remainingMilliseconds"] != int64(0) { - t.Fatalf("remainingMilliseconds detail mismatch: %#v", cliErr.Details) - } -} - -// Verifies disabled native debug-break commands are rejected before Unity dispatch. -func TestRunProjectLocalWaitForDebugBreakRespectsToolSettings(t *testing.T) { - projectRoot := createLaunchTestProject(t) - writeToolSettings(t, projectRoot, `{"disabledTools":["wait-for-debug-break"]}`) - t.Chdir(filepath.Dir(projectRoot)) - - var stdout bytes.Buffer - var stderr bytes.Buffer - code := RunProjectLocal( - context.Background(), - []string{"--project-path", projectRoot, debugBreakWaitCommandName, "--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()) - if envelope.Error.ErrorCode != errorCodeToolDisabled { - t.Fatalf("error code mismatch: %#v", envelope.Error) - } - if envelope.Error.Command != debugBreakWaitCommandName { - 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"}) - - if err == nil { - t.Fatal("expected missing id error") - } - if !strings.Contains(err.Error(), "Missing required option") { - t.Fatalf("error mismatch: %v", err) - } -} - -// Verifies debug-break-status reports the current marker state without waiting for a hit. -func TestRunDebugBreakStatusReturnsCurrentStatus(t *testing.T) { - originalQuery := queryDebugBreakStatus - defer func() { - queryDebugBreakStatus = originalQuery - }() - - queryDebugBreakStatus = func( - ctx context.Context, - connection unityipc.Connection, - id string, - ) (debugBreakStatusResponse, error) { - return debugBreakStatusResponse{Id: id, Status: debugBreakStatusEnabled, IsEnabled: true}, nil - } - - var stdout bytes.Buffer - var stderr bytes.Buffer - code := runDebugBreakStatusCommand( - context.Background(), - unityipc.Connection{ProjectRoot: "/tmp/MyProject"}, - []string{"--id", "jump"}, - &stdout, - &stderr) - - if code != 0 { - t.Fatalf("expected success, got %d with stderr %s", code, stderr.String()) - } - var response debugBreakStatusResponse - 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 { - t.Fatalf("status mismatch: %#v", response) - } -} - -// Verifies debug-break-status requires a marker id. -func TestParseDebugBreakStatusOptionsRequiresID(t *testing.T) { - _, err := parseDebugBreakStatusOptions([]string{}) - - if err == nil { - t.Fatal("expected missing id error") - } - if !strings.Contains(err.Error(), "Missing required option") { - t.Fatalf("error mismatch: %v", err) - } -} - -func parseDebugBreakErrorEnvelope(t *testing.T, payload []byte) cliErrorEnvelope { - t.Helper() - - var envelope cliErrorEnvelope - if err := json.Unmarshal(payload, &envelope); err != nil { - t.Fatalf("stderr is not valid JSON: %v\n%s", err, string(payload)) - } - return envelope -} 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/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..1a4580c11 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) } @@ -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) + } } } @@ -453,7 +468,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/execution_errors.go b/cli/internal/cli/execution_errors.go index a44c90549..cb535e645 100644 --- a/cli/internal/cli/execution_errors.go +++ b/cli/internal/cli/execution_errors.go @@ -1,16 +1,24 @@ package cli +import "fmt" + func compileWaitTimeoutError(projectRoot string) cliError { return cliError{ - ErrorCode: errorCodeCompileWaitTimeout, - Phase: errorPhaseCompileWaiting, - Message: "Compile status wait timed out after 180000ms.", + 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, 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`.", }, } } diff --git a/cli/internal/cli/launch.go b/cli/internal/cli/launch.go index 9f25193af..b77e5bc85 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 } @@ -429,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, + }) +} 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()) + } +} 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_errors.go b/cli/internal/cli/pause_point_errors.go new file mode 100644 index 000000000..8c6a19edc --- /dev/null +++ b/cli/internal/cli/pause_point_errors.go @@ -0,0 +1,144 @@ +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: + 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, + "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 + } +} + +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 pausePointHintPlayModeNotRunning + } + if response.IsPaused { + 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. 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 "" +} + +// 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, + 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_logs.go b/cli/internal/cli/pause_point_logs.go new file mode 100644 index 000000000..c9ec24bc7 --- /dev/null +++ b/cli/internal/cli/pause_point_logs.go @@ -0,0 +1,64 @@ +package cli + +import ( + "context" + "encoding/json" + + "github.com/hatayama/unity-cli-loop/cli/internal/unityipc" +) + +const ( + 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 new file mode 100644 index 000000000..e9ea6724b --- /dev/null +++ b/cli/internal/cli/pause_point_wait.go @@ -0,0 +1,427 @@ +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 + matchingLogsMaxCount int +} + +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 { + // 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 + 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, + 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) + } + + waitErr := pausePointWaitError(connection.ProjectRoot, options, response, state) + 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 { + 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, + matchingLogsMaxCount: pausePointDefaultLogsMaxCount, + } + + 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 + 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, + 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) +} diff --git a/cli/internal/cli/pause_point_wait_test.go b/cli/internal/cli/pause_point_wait_test.go new file mode 100644 index 000000000..dd08f8733 --- /dev/null +++ b/cli/internal/cli/pause_point_wait_test.go @@ -0,0 +1,725 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/hatayama/unity-cli-loop/cli/internal/unityipc" +) + +// 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() { + queryPausePointStatus = originalQuery + pausePointStatusPoll = originalPoll + }() + + responses := []pausePointStatusResponse{ + {Id: "jump", Status: pausePointStatusEnabled, IsEnabled: true}, + {Id: "jump", Status: pausePointStatusHit, IsHit: true, IsPaused: true, HitCount: 1}, + } + requestCount := 0 + queryPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + if id != "jump" { + t.Fatalf("id mismatch: %s", id) + } + response := responses[requestCount] + requestCount++ + return response, nil + } + + response, state, err := waitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ + id: "jump", + timeoutSeconds: 1, + timeout: time.Second, + }) + if err != nil { + t.Fatalf("waitForPausePoint failed: %v", err) + } + if state != pausePointWaitStateHit { + t.Fatalf("state mismatch: %s", state) + } + if response.Status != pausePointStatusHit || response.HitCount != 1 { + t.Fatalf("response mismatch: %#v", response) + } + if requestCount != 2 { + t.Fatalf("request count mismatch: %d", requestCount) + } +} + +// 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() { + queryPausePointStatus = originalQuery + clearPausePointStatus = originalClear + pausePointStatusPoll = originalPoll + }() + + queryPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + return pausePointStatusResponse{ + Id: id, + Status: pausePointStatusEnabled, + IsEnabled: true, + TimeoutSeconds: 1, + ElapsedSinceEnabledMilliseconds: 100, + IsPlaying: true, + IsPaused: false, + Message: "Pause point enabled.", + }, nil + } + + clearedID := "" + clearPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + clearedID = id + return pausePointStatusResponse{Id: id, Status: pausePointStatusCleared}, nil + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runWaitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ + id: "jump", + timeoutSeconds: 1, + timeout: 5 * time.Millisecond, + }, &stdout, &stderr) + + if code != 1 { + t.Fatalf("expected failure, got %d with stdout %s", code, stdout.String()) + } + if clearedID != "jump" { + t.Fatalf("cleared id mismatch: %s", clearedID) + } + if !strings.Contains(stderr.String(), errorCodePausePointWaitTimeout) { + t.Fatalf("timeout error missing from stderr: %s", stderr.String()) + } + 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"] != "Pause point enabled." { + t.Fatalf("markerMessage detail mismatch: %#v", envelope.Error.Details) + } + if envelope.Error.Details["elapsedSinceEnabledMilliseconds"] != float64(100) { + t.Fatalf("elapsedSinceEnabledMilliseconds detail mismatch: %#v", envelope.Error.Details) + } + if envelope.Error.Details["remainingMilliseconds"] != float64(900) { + t.Fatalf("remainingMilliseconds detail mismatch: %#v", envelope.Error.Details) + } +} + +// 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() { + queryPausePointStatus = originalQuery + clearPausePointStatus = originalClear + }() + + requestCount := 0 + queryPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + requestCount++ + if requestCount == 1 { + return pausePointStatusResponse{ + Id: id, + Status: pausePointStatusEnabled, + IsEnabled: true, + }, nil + } + return pausePointStatusResponse{ + Id: id, + Status: pausePointStatusHit, + IsHit: true, + IsPaused: true, + HitCount: 1, + }, nil + } + + clearedID := "" + clearPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + clearedID = id + return pausePointStatusResponse{Id: id, Status: pausePointStatusCleared}, nil + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runWaitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ + id: "jump", + timeoutSeconds: 1, + timeout: 5 * time.Millisecond, + }, &stdout, &stderr) + + if code != 0 { + t.Fatalf("expected success, got %d with stderr %s", code, stderr.String()) + } + if clearedID != "" { + t.Fatalf("marker should not be cleared after final hit: %s", clearedID) + } + 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 != pausePointStatusHit || response.HitCount != 1 { + t.Fatalf("response mismatch: %#v", response) + } + if requestCount != 2 { + t.Fatalf("request count mismatch: %d", requestCount) + } +} + +// Verifies wait-for-pause-point rejects calls before the marker is enabled. +func TestWaitForPausePointReturnsNotEnabledStateImmediately(t *testing.T) { + originalQuery := queryPausePointStatus + defer func() { + queryPausePointStatus = originalQuery + }() + + queryPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + return pausePointStatusResponse{Id: id, Status: pausePointStatusNotEnabled, IsPlaying: true}, nil + } + + response, state, err := waitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ + id: "jump", + timeoutSeconds: 1, + timeout: time.Second, + }) + if err != nil { + t.Fatalf("waitForPausePoint failed: %v", err) + } + if state != pausePointWaitStateNotEnabled { + t.Fatalf("state mismatch: %s", state) + } + if response.Status != pausePointStatusNotEnabled { + t.Fatalf("response mismatch: %#v", response) + } +} + +// Verifies not-enabled failures use the user-facing enabled terminology. +func TestRunWaitForPausePointReportsNotEnabledError(t *testing.T) { + originalQuery := queryPausePointStatus + defer func() { + queryPausePointStatus = originalQuery + }() + + queryPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + return pausePointStatusResponse{ + Id: id, + Status: pausePointStatusNotEnabled, + IsPlaying: true, + IsPaused: false, + Message: "Pause point is not enabled.", + }, 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 != 1 { + t.Fatalf("expected failure, got %d with stdout %s", code, stdout.String()) + } + envelope := parsePausePointErrorEnvelope(t, stderr.Bytes()) + if envelope.Error.ErrorCode != errorCodePausePointNotEnabled { + t.Fatalf("error code mismatch: %#v", envelope.Error) + } + 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 { + t.Fatalf("play state details mismatch: %#v", envelope.Error.Details) + } +} + +// 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.matchingLogsMaxCount != pausePointDefaultLogsMaxCount { + t.Fatalf("default matching-log options mismatch: %#v", defaults) + } + + options, err := parseWaitForPausePointOptions([]string{ + "--id", "jump", "--matching-logs-max-count", "5", + }) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + 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 always embeds marker-matching logs. +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, + 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 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() { + 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 []pausePointMatchingLog{}, nil + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runWaitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ + 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 an explicit empty array: %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, + 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 always embed marker-matching logs best-effort. +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, + 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()) + // 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) + } +} + +// 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. 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.", + }, + } + + 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 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", + timeoutSeconds: 1, + }, hitResponse, pausePointWaitStateTimeout) + if _, exists := timeoutErr.Details["hint"]; exists { + t.Fatalf("hint should be omitted when no diagnosis applies: %#v", timeoutErr.Details) + } + + clearedResponse := pausePointStatusResponse{Id: "jump", Status: pausePointStatusCleared, IsPlaying: true} + clearedErr := pausePointWaitError("/tmp/MyProject", waitForPausePointOptions{ + id: "jump", + timeoutSeconds: 1, + }, clearedResponse, pausePointWaitStateCleared) + if _, exists := clearedErr.Details["hint"]; exists { + t.Fatalf("hint should be omitted for cleared markers: %#v", clearedErr.Details) + } +} + +// Verifies expired markers report no remaining enabled lifetime. +func TestPausePointExpiredErrorReportsNoRemainingTime(t *testing.T) { + response := pausePointStatusResponse{ + Id: "jump", + Status: pausePointStatusExpired, + TimeoutSeconds: 1, + ElapsedSinceEnabledMilliseconds: 1200, + IsPlaying: true, + Message: "Pause point expired before it was hit.", + } + + cliErr := pausePointWaitError("/tmp/MyProject", waitForPausePointOptions{ + id: "jump", + timeoutSeconds: 1, + }, response, pausePointWaitStateExpired) + + if cliErr.ErrorCode != errorCodePausePointExpired { + t.Fatalf("error code mismatch: %#v", cliErr) + } + if cliErr.Details["remainingMilliseconds"] != int64(0) { + t.Fatalf("remainingMilliseconds detail mismatch: %#v", cliErr.Details) + } +} + +// 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-pause-point"]}`) + t.Chdir(filepath.Dir(projectRoot)) + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := RunProjectLocal( + context.Background(), + []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 := parsePausePointErrorEnvelope(t, stderr.Bytes()) + if envelope.Error.ErrorCode != errorCodeToolDisabled { + t.Fatalf("error code mismatch: %#v", envelope.Error) + } + if envelope.Error.Command != pausePointWaitCommandName { + t.Fatalf("command mismatch: %#v", envelope.Error) + } +} + +// 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") + } + if !strings.Contains(err.Error(), "Missing required option") { + t.Fatalf("error mismatch: %v", err) + } +} + +// Verifies pause-point-status reports the current marker state without waiting for a hit. +func TestRunPausePointStatusReturnsCurrentStatus(t *testing.T) { + originalQuery := queryPausePointStatus + defer func() { + queryPausePointStatus = originalQuery + }() + + queryPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + return pausePointStatusResponse{Id: id, Status: pausePointStatusEnabled, IsEnabled: true}, nil + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runPausePointStatusCommand( + context.Background(), + unityipc.Connection{ProjectRoot: "/tmp/MyProject"}, + []string{"--id", "jump"}, + &stdout, + &stderr) + + if code != 0 { + t.Fatalf("expected success, got %d with stderr %s", code, stderr.String()) + } + var response pausePointStatusResponse + if err := json.Unmarshal(stdout.Bytes(), &response); err != nil { + t.Fatalf("stdout is not valid JSON: %v\n%s", err, stdout.String()) + } + if response.Status != pausePointStatusEnabled { + t.Fatalf("status mismatch: %#v", response) + } +} + +// 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") + } + if !strings.Contains(err.Error(), "Missing required option") { + t.Fatalf("error mismatch: %v", err) + } +} + +func parsePausePointErrorEnvelope(t *testing.T, payload []byte) cliErrorEnvelope { + t.Helper() + + var envelope cliErrorEnvelope + if err := json.Unmarshal(payload, &envelope); err != nil { + t.Fatalf("stderr is not valid JSON: %v\n%s", err, string(payload)) + } + return envelope +} diff --git a/cli/internal/cli/run.go b/cli/internal/cli/run.go index 5e02fe12d..96ad4f67a 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 { @@ -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", 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) 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" } } }