diff --git a/.agents/skills/uloop-compile/SKILL.md b/.agents/skills/uloop-compile/SKILL.md index 108abc5a84..3febf89300 100644 --- a/.agents/skills/uloop-compile/SKILL.md +++ b/.agents/skills/uloop-compile/SKILL.md @@ -18,7 +18,7 @@ uloop compile [--force-recompile] [--no-wait-for-domain-reload] [--stop-on-exter | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--force-recompile` | flag | - | Full recompile plus domain reload. Rarely needed — see "When to use --force-recompile" below | +| `--force-recompile` | flag | - | Full recompile plus domain reload. Almost never needed: a plain compile already detects externally edited files, and the forced reload can freeze large projects and come back as `COMPILE_RESULT_UNKNOWN`. | | `--no-wait-for-domain-reload` | flag | - | Return before Domain Reload completion | | `--stop-on-external-scene-changes` | flag | - | Stop before compilation if open Scene files changed externally instead of auto-reloading them | diff --git a/.agents/skills/uloop-control-play-mode/SKILL.md b/.agents/skills/uloop-control-play-mode/SKILL.md index 07a8b91604..102b90f0e2 100644 --- a/.agents/skills/uloop-control-play-mode/SKILL.md +++ b/.agents/skills/uloop-control-play-mode/SKILL.md @@ -1,7 +1,7 @@ --- name: uloop-control-play-mode toolName: control-play-mode -description: "Control Unity Editor Play Mode. Use to start, stop, pause, or step Play Mode, or query its state without side effects, for runtime behavior checks and frame inspection." +description: "Control Unity Editor Play Mode. Use to Play (or Resume, its alias), Stop, Pause, or Step Play Mode, or query Status without side effects, for runtime behavior checks and frame inspection." --- # uloop control-play-mode @@ -18,7 +18,7 @@ uloop control-play-mode [options] | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | string | `Play` | Action to perform: `Play`, `Stop`, `Pause`, `Step`, `Status`, `Resume` (alias of `Play`) | +| `--action` | string | `Play` | `Play` - start Play Mode, `Stop` - stop Play Mode, `Pause` - pause Play Mode, `Step` - advance one frame while paused, `Status` - report current state without changing anything, `Resume` - alias of Play in every state, including starting Play Mode when stopped | | `--timeout-seconds` | integer | `180` | Maximum seconds to wait for the requested play mode state | ## Output diff --git a/.agents/skills/uloop-execute-dynamic-code/SKILL.md b/.agents/skills/uloop-execute-dynamic-code/SKILL.md index 23aec2b682..f02d3336d9 100644 --- a/.agents/skills/uloop-execute-dynamic-code/SKILL.md +++ b/.agents/skills/uloop-execute-dynamic-code/SKILL.md @@ -16,10 +16,16 @@ Live state injection: when a running PlayMode session is merely in the wrong sta ## Parameters -- `--code ''`: Inline C# statements to execute. Use direct statements only; `return` is optional, and `using` directives may appear at the top of the snippet. +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--code` | string | - | Inline C# statements to execute. Direct statements only; `return` is optional, and `using` directives may appear at the top of the snippet. | +| `--parameters` | object | - | Shell-quoted JSON object literal for reusing a snippet with varying data or keeping values outside the code. Values are exposed as `parameters["param0"]`, `parameters["param1"]`, and so on. Omit for most snippets; never pass a JSON string value. | +| `--wait-for-domain-reload` | flag | - | Wait for Domain Reload recovery after snippets that intentionally trigger Unity script reload or import work. Omit for normal inspection and editor-state workflows. | +| `--yield-to-foreground-requests` | flag | - | Allow foreground requests to preempt this execution | + +CLI-only flag, accepted instead of a schema parameter: + - `--code-file `: Read the C# statements from a file instead of `--code`. Use this when the active shell or launcher cannot preserve inline code exactly. Exactly one of `--code` or `--code-file` is required; combining them is an error. -- `--parameters {}` (advanced, optional): Pass a shell-quoted JSON object literal 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. Do not pass a JSON string value such as `"{\"param0\":\"value\"}"`. -- `--wait-for-domain-reload` (optional): Wait for Domain Reload recovery after snippets that intentionally trigger Unity script reload or import work. Omit this for normal inspection and editor-state workflows. ## Code Rules diff --git a/.agents/skills/uloop-pause-point/SKILL.md b/.agents/skills/uloop-pause-point/SKILL.md index 48f1eef9c7..d68d90dfd2 100644 --- a/.agents/skills/uloop-pause-point/SKILL.md +++ b/.agents/skills/uloop-pause-point/SKILL.md @@ -16,7 +16,9 @@ Use this small loop for one representative frame you care about. No source edit uloop enable-pause-point --file Assets/Scripts/Enemy.cs --line 42 --timeout-seconds 30 --await --trigger "simulate-keyboard --action Press --key Space" ``` -`--trigger` runs a single uloop subcommand in-process only after the marker's arming is confirmed, so there is no arm-vs-input race and nothing needs to run in the background. The hit response additionally carries `TriggerResult` with the triggered command's own response (or, when the trigger was skipped, `Completed: false` and the reason in `Error`). The trigger string cannot name another pause-point wait (`await-pause-point`/`enable-pause-point`) and cannot pass `--project-path` — the enclosing command's project is used. `await-pause-point --id --trigger ...` accepts the same flag for a marker enabled earlier. Both commands also accept `--resume-play` — see Fast-Progressing Games. +Digit keys are `Digit0`-`Digit9` or `Numpad0`-`Numpad9` — bare `0`-`9` is rejected. + +Before writing a `--trigger` command that differs from the example, load the skill of the tool you are about to trigger. `--trigger` runs a single uloop subcommand in-process only after the marker's arming is confirmed, so the input cannot land before arming and nothing needs to run in the background. One race does remain: the marker itself can hit before the trigger executes (for example on a line that runs every frame), in which case the trigger is rejected because PlayMode is already paused and runs nothing — the hit response then carries `TriggerFailed: true` at the top level and a `Warning` explaining that no input reached the game (the triggered command's own response stays in `TriggerResult`), so do not treat such a hit as input-driven. If the trigger command itself is rejected before it runs — its argument parsing fails (`INVALID_ARGUMENT`) or the command name is unknown (`UNKNOWN_COMMAND`) — the wait is abandoned immediately with a `PAUSE_POINT_TRIGGER_FAILED` error instead of waiting out `--timeout-seconds`: the marker stays armed, a PlayMode resumed by `--resume-play` is paused again, and `Error.NextActions` carries the recovery commands — fix the trigger value and re-run the same command. The hit response additionally carries `TriggerResult` with the triggered command's own response (or, when the trigger was skipped, `Completed: false` and the reason in `Error`). The trigger string cannot name another pause-point wait (`await-pause-point`/`enable-pause-point`) and cannot pass `--project-path` — the enclosing command's project is used. `await-pause-point --id --trigger ...` accepts the same flag for a marker enabled earlier. Both commands also accept `--resume-play` — see Fast-Progressing Games. When the game reaches the line on its own, omit `--trigger`. Fall back to split steps only when the triggering action is not a single uloop command (several inputs in sequence, an external event): run `enable-pause-point` without `--await` in the foreground (its response returning is the arm confirmation), then start `uloop await-pause-point --id ` in the background, then send the inputs. Do not approximate arm-waiting with a fixed sleep after a backgrounded enable. @@ -30,6 +32,63 @@ The response returns the derived marker `Id` (`Assets/Scripts/Enemy.cs:42`), the A hit pauses Unity at the next frame boundary — the patched method and the rest of that frame still run to completion. Only `CapturedVariables` is evidence of the values at the patched line; state read after the pause (for example via `execute-dynamic-code`) may already have advanced past it. +## Parameters + +One skill covers several commands, so each command's schema parameters have their own table below. +CLI-only flags (`--await`, `--trigger`, `--resume-play`, `--expect`, `--captured-variables`, +`--captured-variable-names`, `--matching-logs-max-count`) are described in the sections above; only +parameters Unity itself accepts appear here. + +### enable-pause-point + +Enable a pause point so Unity pauses when that code path is reached, either by a named UloopPausePoint.Pause marker (Id) or by resolving a source file and line (File+Line) + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--id` | string | - | Named pause point id passed to UloopPausePoint.Pause. Mutually exclusive with File/Line | +| `--file` | string | - | Project-relative source file path to patch a pause point into. Requires Line; mutually exclusive with Id | +| `--line` | integer | - | 1-based source line to resolve within File. Requires File; mutually exclusive with Id | +| `--timeout-seconds` | integer | `30` | Seconds before the enable request expires and stops pausing late hits | +| `--mode` | enum | `single-shot` | Capture mode: single-shot pauses once, continuous pauses on every hit, trace records hits without pausing | +| `--max-history` | integer | `20` | Maximum number of captured hit frames to retain (1-100) | +| `--max-preview-elements` | integer | `10` | Maximum number of elements to include in a captured collection's preview (1-1000). The value set at enable time also caps the previews in every later pause-point-status response for that marker; status has no flag to change it. | + +### clear-pause-point + +Clear one or all named UloopPausePoint.Pause markers + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--id` | string | - | Named pause point id to clear | +| `--all` | flag | - | Clear every active pause point marker | + +### enable-watch + +Register a C# expression to evaluate on each paused Play Mode step + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--id` | string | - | Unique watch expression identifier | +| `--expression` | string | - | C# expression returning an object; UloopPausePoint.TryGetCapturedValue can read the latest raw capture | +| `--max-history` | integer | `20` | Maximum number of watch evaluations to retain (1-100) | + +### get-watch-values + +Show registered watch expression values and bounded evaluation history + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--id` | string | - | Optional watch expression identifier; omit to return all watches | + +### clear-watch + +Clear one or all registered C# watch expressions + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--id` | string | - | Watch expression identifier to clear | +| `--all` | flag | - | Clear every registered watch expression | + ## Capture Modes and History Choose the capture mode when enabling a pause point: @@ -51,9 +110,9 @@ Every hit response embeds `CapturedVariables`: the method's in-scope locals, its - The snapshot is taken **before** the resolved line executes, exactly like an IDE breakpoint on that line. To inspect a value after an assignment, place the pause point on the following line. - `Scope` is `Local`, `Parameter`, `InstanceField`, or `This`. The synthetic `this` entry identifies which instance or GameObject was hit via `UnityObjectPath` and `UnityObjectInstanceId`; `UnityEngine.Object` values carry the same handle fields for follow-up digs with `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code`. - `--captured-variables names` on `await-pause-point`/`pause-point-status` drops every `Value` and keeps `Name`/`Scope`/`TypeName` — use it first on field-heavy classes, then fetch full values with a plain `pause-point-status` call. -- When the response would be dominated by variables you do not need, pass `--captured-variable-names velocity,this` (comma-separated, exact match on `Name`) to keep only those entries; it composes with `--captured-variables full|names`. -- Pass `--expect 'name=value'` (repeatable; on `await-pause-point` and `enable-pause-point --await`, not `pause-point-status`) to have the CLI compare captured variables against expected values; the response includes an `Expectations` array and `AllExpectationsPassed`, so you do not need to eyeball the JSON. Matching is string equality against the serialized value. -- Collection values (arrays, `List`, dictionaries, plain objects) render as a JSON preview capped at 10 elements by default. When the elements you need sit past that cap (a 10x20 grid, a long list), re-enable with `--max-preview-elements ` (1–1000). +- When the response would be dominated by variables you do not need, pass `--captured-variable-names velocity,this` (comma-separated, exact match on `Name`) to keep only those entries; it composes with `--captured-variables full|names`. `CapturedVariablesTruncated` in the response reports truncation at Unity-side capture time and is unrelated to this name filter — it can be `true` even when every requested name was found. Requested names that matched nothing are listed in `CapturedVariableNamesNotFound`, so a partial match is visible without comparing the response against the request by hand. +- Pass `--expect 'name=value'` (repeatable; on `await-pause-point`, `enable-pause-point --await`, and `pause-point-status`) to have the CLI compare captured variables against expected values; the response includes an `Expectations` array and `AllExpectationsPassed`, so you do not need to eyeball the JSON. Matching is string equality against the serialized value. On `pause-point-status` a marker that has not been hit yet reports each expectation as not found, and the verdict never changes the exit code — a polling loop reads `AllExpectationsPassed`, not the exit status. +- Collection values (arrays, `List`, dictionaries, plain objects) render as a JSON preview capped at 10 elements by default. When the elements you need sit past that cap (a 10x20 grid, a long list), re-enable with `--max-preview-elements ` (1–1000). The value set at enable time also caps the previews in every later `pause-point-status` response for that marker — status has no flag to change it. - While Unity is still paused, `UloopPausePoint.TryGetCapturedValue("name")` (and `"this"`) returns live captured references for `execute-dynamic-code`; the return is a `(bool Found, object Value)` tuple, and the holder clears on resume. (file:line marker hits only — id-only markers store no capture) These are **live objects in their frame-completed state, not snapshots** — use them only to dig further into objects that are still alive, never to reconstruct what a value was at the paused line. For snapshot timing, preview/truncation caps, Unity-object `Value` semantics, capture-time vs live evidence, `Warning`/`MatchingLogs`, marker freshness, and the raw capture API, read [references/captured-variables.md](references/captured-variables.md). @@ -93,7 +152,7 @@ For "N frames after the input" (for example, three frames after a key press), ad A pause point hits only when control flow reaches the patched line (or the `Pause(id)` call). `simulate-keyboard` returning `PressEdgeObserved=true` means the input edge was observed, not that your target game logic has reached the pause line yet. -If a `simulate-*` command instead returns a failure whose message says PlayMode is paused, suspect a pause point hit rather than an unrelated failure: an active pause point can make PlayMode paused mid-simulation, and the `simulate-*` call surfaces that as a preflight failure. Check `uloop pause-point-status --id ` first to confirm the hit before treating it as a bug in the simulated action itself. +If a `simulate-*` command instead returns a failure whose message says PlayMode is paused, suspect a pause point hit rather than an unrelated failure: an active pause point can make PlayMode paused mid-simulation, and the `simulate-*` call surfaces that as a preflight failure. The failure response names the responsible marker in `RejectedByActivePausePointId`. Check `uloop pause-point-status --id ` first to confirm the hit before treating it as a bug in the simulated action itself. ## When To Use @@ -116,9 +175,11 @@ When the game advances on its own (timers, gravity, spawners), any state you arr ```bash uloop enable-pause-point --file Assets/Scripts/Enemy.cs --line 42 --timeout-seconds 60 \ - --await --resume-play --trigger "simulate-keyboard --action Press --key Space" + --await --resume-play --trigger "simulate-keyboard --action Press --key Digit3" ``` +Digit keys are `Digit0`-`Digit9` or `Numpad0`-`Numpad9` — bare `0`-`9` is rejected. + `--resume-play` (requires `--await`; `await-pause-point` accepts it too) resumes a paused PlayMode after the marker's arming is confirmed and before `--trigger` is dispatched. Size `--timeout-seconds` generously when arming while paused: a manual Pause does not freeze the marker countdown (see Timeout Checks). For `ResumePlayResult` semantics, why `Time.timeScale = 0` is not a substitute for pausing, the residual post-resume race, and why direct state writes may not stick while paused, read [references/fast-progressing-games.md](references/fast-progressing-games.md). diff --git a/.agents/skills/uloop-pause-point/references/captured-variables.md b/.agents/skills/uloop-pause-point/references/captured-variables.md index e19809a8cc..6588496374 100644 --- a/.agents/skills/uloop-pause-point/references/captured-variables.md +++ b/.agents/skills/uloop-pause-point/references/captured-variables.md @@ -78,6 +78,7 @@ return value; ``` The references are live objects in their frame-completed state: anything the hit's method changed — or destroyed — after the patched line is already applied, so a captured object can read as destroyed/null here even though `CapturedVariables` shows its pre-line field values intact. +Concrete example: with a marker on the line right before `Destroy(obj)`, `TryGetCapturedValue("obj")` returns the frame-completed object — already destroyed — while the pre-destroy field values are still readable in `CapturedVariables`. The holder clears when Unity resumes (not when you `Step` while still paused), when the matching pause point is cleared, when a new hit replaces the snapshot, or when PlayMode exits. After resume, `TryGetCapturedValue` returns `Found=false`. Re-enabling the same pause point while still paused (for example to refresh its timeout during a step session) keeps the held references, because a re-enable does not resume Unity. diff --git a/.agents/skills/uloop-pause-point/references/fast-progressing-games.md b/.agents/skills/uloop-pause-point/references/fast-progressing-games.md index a7b0c300a6..60017b8600 100644 --- a/.agents/skills/uloop-pause-point/references/fast-progressing-games.md +++ b/.agents/skills/uloop-pause-point/references/fast-progressing-games.md @@ -15,12 +15,14 @@ uloop execute-dynamic-code --code '...' # 3) One call: confirm the marker armed, resume PlayMode, fire the input, await the hit uloop enable-pause-point --file Assets/Scripts/Enemy.cs --line 42 --timeout-seconds 60 \ - --await --resume-play --trigger "simulate-keyboard --action Press --key Space" + --await --resume-play --trigger "simulate-keyboard --action Press --key Digit3" ``` +Digit keys are `Digit0`-`Digit9` or `Numpad0`-`Numpad9` — bare `0`-`9` is rejected. + ## --resume-play Semantics -`--resume-play` runs after the marker's arming is confirmed and before `--trigger` is dispatched: it resumes PlayMode only when PlayMode is actually paused, and reports what it did in `ResumePlayResult` (`WasPaused` / `Resumed` / `Error`). If the resume fails, the trigger is not dispatched and `TriggerResult.Error` says so. When the game reaches the line on its own after resuming (gravity, physics), omit `--trigger` and keep `--resume-play`. +`--resume-play` runs after the marker's arming is confirmed and before `--trigger` is dispatched: it resumes PlayMode only when PlayMode is actually paused, and reports what it did in `ResumePlayResult` (`WasPaused` / `Resumed` / `Error`; an abandoned wait adds `Repaused` / `RepauseError`). If the resume fails, the trigger is not dispatched and `TriggerResult.Error` says so. If the trigger itself is rejected before it runs, the wait is abandoned and the resume is undone: `Repaused: true` (or `RepauseError`) reports PlayMode being paused again, so gameplay cannot consume the preserved marker while the trigger value is being fixed. When the game reaches the line on its own after resuming (gravity, physics), omit `--trigger` and keep `--resume-play`. ## Do Not Use Time.timeScale = 0 diff --git a/.agents/skills/uloop-record-input/SKILL.md b/.agents/skills/uloop-record-input/SKILL.md index 43f966dc3d..d3481797b3 100644 --- a/.agents/skills/uloop-record-input/SKILL.md +++ b/.agents/skills/uloop-record-input/SKILL.md @@ -28,9 +28,11 @@ uloop record-input --action Stop --output-path scripts/my-play.json | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | enum | `Start` | `Start` - begin recording, `Stop` - stop and save | -| `--output-path` | string | auto | Save path. Auto-generates under `.uloop/outputs/InputRecordings/` | -| `--keys` | string | `""` | Comma-separated key filter. Empty = all common game keys | +| `--action` | enum | `Start` | `Start` - begin recording input, `Stop` - stop recording and save to file | +| `--output-path` | string | auto | Save path for the recording JSON. When empty, auto-generates under `.uloop/outputs/InputRecordings/` | +| `--keys` | string | `""` | Comma-separated key filter of Input System Key enum names (for example `W,A,S,D,Space`). Case-insensitive. Digit keys use `Digit0`-`Digit9` or `Numpad0`-`Numpad9`, not bare `0`-`9`; a name that matches no key fails the command instead of being dropped from the filter. Empty records all common game keys | +| `--delay-seconds` | integer | `3` | Countdown delay in seconds before recording starts (0-10). Gives time to switch focus to Game View. | +| `--no-show-overlay` | flag | - | Hide the recording countdown and REC indicator overlay | ## Deterministic Replay diff --git a/.agents/skills/uloop-replay-input/SKILL.md b/.agents/skills/uloop-replay-input/SKILL.md index 051b96759c..1ae5450c37 100644 --- a/.agents/skills/uloop-replay-input/SKILL.md +++ b/.agents/skills/uloop-replay-input/SKILL.md @@ -31,8 +31,8 @@ uloop replay-input --action Stop | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | enum | `Start` | `Start`, `Stop`, `Status` | -| `--input-path` | string | auto | JSON path. Auto-detects latest in `.uloop/outputs/InputRecordings/` | +| `--action` | enum | `Start` | `Start` - begin replaying, `Stop` - stop mid-way, `Status` - check progress | +| `--input-path` | string | auto | Path to the recording JSON. When empty, auto-detects the latest recording in `.uloop/outputs/InputRecordings/` | | `--no-show-overlay` | flag | - | Hide replay progress overlay | | `--loop` | flag | - | Loop continuously | diff --git a/.agents/skills/uloop-screenshot/SKILL.md b/.agents/skills/uloop-screenshot/SKILL.md index 942de5c6cf..435880c53b 100644 --- a/.agents/skills/uloop-screenshot/SKILL.md +++ b/.agents/skills/uloop-screenshot/SKILL.md @@ -18,12 +18,12 @@ uloop screenshot [--window-name ] [--resolution-scale ] [--match-mo | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--window-name` | string | `Game` | Window name to capture. Ignored when `--capture-mode rendering`. When the Game tab is Device Simulator and the title is `Simulator`, default `Game` falls back to `Simulator`. | +| `--window-name` | string | `Game` | Window name to capture (for example `Game`, `Scene`, `Console`, `Inspector`). Ignored when `--capture-mode rendering`. When the Game tab is Device Simulator and the title is Simulator, default Game falls back to Simulator. | | `--resolution-scale` | number | `1.0` | Resolution scale (0.1 to 1.0) | | `--match-mode` | enum | `exact` | Window name matching mode: `exact`, `prefix`, or `contains`. Ignored when `--capture-mode rendering`. | -| `--capture-mode` | enum | `window` | `window`=capture EditorWindow including toolbar, `rendering`=capture game rendering only (PlayMode required, coordinates match simulate-mouse) | +| `--capture-mode` | enum | `window` | `window` - capture EditorWindow including toolbar, `rendering` - capture game rendering only (PlayMode required). Rendering screenshots return `ScreenshotToInputFormula` for converting raw image pixels before calling simulate-mouse-input or raycast. | | `--output-directory` | string | `""` | Output directory path for saving screenshots. When empty, uses default path (.uloop/outputs/Screenshots/). Accepts absolute paths. | -| `--annotate-elements` | flag | - | Annotate interactive UI elements with index labels and interaction hints (A / CLICK, B / DRAG, ...). Only works with `--capture-mode rendering` in PlayMode. | +| `--annotate-elements` | flag | - | Annotate interactive UI elements with index labels and interaction hints (A / CLICK, B / DRAG, ...). The response includes an `AnnotatedElements` array with element metadata sorted by z-order. Only works with `--capture-mode rendering` in PlayMode. | | `--annotate-raycast-grid` | flag | - | Annotate clustered 3D physics collider candidates as `PhysicsCollider` entries in `AnnotatedElements`. Uses `Camera.main` visibility and the same top-left Game View coordinates as `simulate-mouse-input`. Only works with `--capture-mode rendering` in PlayMode. | | `--raycast-layer-mask` | string | `""` | Comma-separated physics layer names to narrow which layers `--annotate-raycast-grid` clusters. Hits are limited to layers also visible to `Camera.main.cullingMask`. When omitted, clusters against `Physics.DefaultRaycastLayers`. | | `--elements-only` | flag | - | Return only annotated element JSON without capturing a screenshot image. Requires `--annotate-elements` or `--annotate-raycast-grid`, and `--capture-mode rendering` in PlayMode. | diff --git a/.agents/skills/uloop-simulate-keyboard/SKILL.md b/.agents/skills/uloop-simulate-keyboard/SKILL.md index c3e601ce69..fd8b1887ab 100644 --- a/.agents/skills/uloop-simulate-keyboard/SKILL.md +++ b/.agents/skills/uloop-simulate-keyboard/SKILL.md @@ -1,7 +1,7 @@ --- name: uloop-simulate-keyboard toolName: simulate-keyboard -description: "Simulate keyboard input in PlayMode through Unity Input System. Use for key presses, holds, releases, and game controls such as WASD or Space." +description: "Simulate keyboard input in PlayMode through Unity Input System. Use for key presses, holds (via Press --duration or KeyDown/KeyUp), releases, and game controls such as WASD or Space. Requires the Input System package (com.unity.inputsystem)." --- # Task @@ -27,7 +27,7 @@ uloop simulate-keyboard --action ReleaseAll | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | enum | `Press` | `Press`, `KeyDown`, `KeyUp`, `ReleaseAll` | +| `--action` | enum | `Press` | `Press` - one-shot key tap (Down then Up), `KeyDown` - hold key down, `KeyUp` - release held key, `ReleaseAll` - force-release every tracked and device-pressed key (allowed while PlayMode is paused; use after a pause-point interruption leaves key state inconsistent) | | `--key` | string | (required except `ReleaseAll`) | Key name matching Input System Key enum (e.g. `W`, `Space`, `LeftShift`, `A`, `Enter`). Case-insensitive. Digit keys use `Digit0`-`Digit9` or `Numpad0`-`Numpad9`, not bare `0`-`9`. Not used by `ReleaseAll`. | | `--duration` | number | `0` | Hold duration in seconds for Press action (0 = one-shot tap). Ignored by KeyDown/KeyUp/ReleaseAll. | diff --git a/.agents/skills/uloop-simulate-mouse-input/SKILL.md b/.agents/skills/uloop-simulate-mouse-input/SKILL.md index a4029fe698..cc6a406c73 100644 --- a/.agents/skills/uloop-simulate-mouse-input/SKILL.md +++ b/.agents/skills/uloop-simulate-mouse-input/SKILL.md @@ -1,7 +1,7 @@ --- name: uloop-simulate-mouse-input toolName: simulate-mouse-input -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." +description: "Simulate Mouse.current input in PlayMode through Unity Input System. Use for gameplay mouse clicks, long-press (LongPress), movement delta (MoveDelta/SmoothDelta), or scroll. Use simulate-mouse-ui for UI. Requires the Input System package and Active Input Handling set to 'Input System Package (New)' or 'Both'." --- # Task @@ -17,6 +17,11 @@ Simulate mouse input via Input System in Unity PlayMode. 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 +Two rules while verifying: + +- Do not touch the physical mouse or keyboard, and keep the OS pointer off the Unity window — real device input mixes into the same `Mouse.current` state this tool injects. +- Read the starting values you will assert against immediately before firing the input; a value measured earlier in the session may have changed. + ## Tool Reference ```bash @@ -27,7 +32,7 @@ uloop simulate-mouse-input --action [options] | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | enum | `Click` | `Click`, `LongPress`, `MoveDelta`, `SmoothDelta`, `Scroll` | +| `--action` | enum | `Click` | `Click` - inject button press+release, `LongPress` - inject button hold for `--duration` seconds, `MoveDelta` - inject mouse delta (one-shot), `SmoothDelta` - inject mouse delta smoothly over `--duration` seconds, `Scroll` - inject scroll wheel | | `--x` | number | `0` | Target X position in Game View pixels (origin: top-left). Used by Click and LongPress. Use `AnnotatedElements[].SimX`, or raw image pixels converted with `ScreenshotToInputFormula`. | | `--y` | number | `0` | Target Y position in Game View pixels (origin: top-left). Used by Click and LongPress. Use `AnnotatedElements[].SimY`, or raw image pixels converted with `ScreenshotToInputFormula`. | | `--button` | enum | `Left` | Mouse button: `Left`, `Right`, `Middle`. Used by Click and LongPress. | @@ -35,7 +40,7 @@ uloop simulate-mouse-input --action [options] | `--delta-x` | number | `0` | Delta X in pixels for MoveDelta/SmoothDelta. Positive = right. | | `--delta-y` | number | `0` | Delta Y in pixels for MoveDelta/SmoothDelta. Positive = up. | | `--scroll-x` | number | `0` | Horizontal scroll delta for Scroll action. | -| `--scroll-y` | number | `0` | Vertical scroll delta for Scroll action. Typically 120 per notch. | +| `--scroll-y` | number | `0` | Vertical scroll delta for Scroll action. Positive = up, negative = down. Typically 120 per notch. | ### Actions diff --git a/.agents/skills/uloop-simulate-mouse-ui/SKILL.md b/.agents/skills/uloop-simulate-mouse-ui/SKILL.md index b317e85619..0a187f0c03 100644 --- a/.agents/skills/uloop-simulate-mouse-ui/SKILL.md +++ b/.agents/skills/uloop-simulate-mouse-ui/SKILL.md @@ -28,11 +28,11 @@ uloop simulate-mouse-ui --action --x --y [options] | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | enum | `Click` | `Click`, `Drag`, `DragStart`, `DragMove`, `DragEnd`, `LongPress` | +| `--action` | enum | `Click` | `Click` - click at position, `Drag` - one-shot drag, `DragStart` - begin drag and hold, `DragMove` - move while holding drag, `DragEnd` - release drag, `LongPress` - press and hold for `--duration` seconds | | `--x` | number | `0` | Target X position in screen pixels (origin: top-left). For Drag action, this is the destination. | | `--y` | number | `0` | Target Y position in screen pixels (origin: top-left). For Drag action, this is the destination. | -| `--from-x` | number | `0` | Start X position for Drag action. Drag starts here and moves to x,y. | -| `--from-y` | number | `0` | Start Y position for Drag action. Drag starts here and moves to x,y. | +| `--from-x` | number | `0` | Start X position for Drag action (origin: top-left). Drag starts here and moves to `--x`,`--y`. | +| `--from-y` | number | `0` | Start Y position for Drag action (origin: top-left). Drag starts here and moves to `--x`,`--y`. | | `--drag-speed` | number | `2000` | Drag speed in pixels per second (0 for instant). 2000 is fast (default), 200 is slow enough to watch. Applies to Drag, DragMove, and DragEnd actions. | | `--duration` | number | `0.5` | Hold duration in seconds for LongPress action. | | `--button` | enum | `Left` | Mouse button. `Click` and `LongPress` support `Left`, `Right`, and `Middle`. Drag actions support `Left` only; other buttons return an error. | diff --git a/.claude/skills/uloop-compile/SKILL.md b/.claude/skills/uloop-compile/SKILL.md index 108abc5a84..3febf89300 100644 --- a/.claude/skills/uloop-compile/SKILL.md +++ b/.claude/skills/uloop-compile/SKILL.md @@ -18,7 +18,7 @@ uloop compile [--force-recompile] [--no-wait-for-domain-reload] [--stop-on-exter | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--force-recompile` | flag | - | Full recompile plus domain reload. Rarely needed — see "When to use --force-recompile" below | +| `--force-recompile` | flag | - | Full recompile plus domain reload. Almost never needed: a plain compile already detects externally edited files, and the forced reload can freeze large projects and come back as `COMPILE_RESULT_UNKNOWN`. | | `--no-wait-for-domain-reload` | flag | - | Return before Domain Reload completion | | `--stop-on-external-scene-changes` | flag | - | Stop before compilation if open Scene files changed externally instead of auto-reloading them | diff --git a/.claude/skills/uloop-control-play-mode/SKILL.md b/.claude/skills/uloop-control-play-mode/SKILL.md index 07a8b91604..102b90f0e2 100644 --- a/.claude/skills/uloop-control-play-mode/SKILL.md +++ b/.claude/skills/uloop-control-play-mode/SKILL.md @@ -1,7 +1,7 @@ --- name: uloop-control-play-mode toolName: control-play-mode -description: "Control Unity Editor Play Mode. Use to start, stop, pause, or step Play Mode, or query its state without side effects, for runtime behavior checks and frame inspection." +description: "Control Unity Editor Play Mode. Use to Play (or Resume, its alias), Stop, Pause, or Step Play Mode, or query Status without side effects, for runtime behavior checks and frame inspection." --- # uloop control-play-mode @@ -18,7 +18,7 @@ uloop control-play-mode [options] | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | string | `Play` | Action to perform: `Play`, `Stop`, `Pause`, `Step`, `Status`, `Resume` (alias of `Play`) | +| `--action` | string | `Play` | `Play` - start Play Mode, `Stop` - stop Play Mode, `Pause` - pause Play Mode, `Step` - advance one frame while paused, `Status` - report current state without changing anything, `Resume` - alias of Play in every state, including starting Play Mode when stopped | | `--timeout-seconds` | integer | `180` | Maximum seconds to wait for the requested play mode state | ## Output diff --git a/.claude/skills/uloop-execute-dynamic-code/SKILL.md b/.claude/skills/uloop-execute-dynamic-code/SKILL.md index 23aec2b682..f02d3336d9 100644 --- a/.claude/skills/uloop-execute-dynamic-code/SKILL.md +++ b/.claude/skills/uloop-execute-dynamic-code/SKILL.md @@ -16,10 +16,16 @@ Live state injection: when a running PlayMode session is merely in the wrong sta ## Parameters -- `--code ''`: Inline C# statements to execute. Use direct statements only; `return` is optional, and `using` directives may appear at the top of the snippet. +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--code` | string | - | Inline C# statements to execute. Direct statements only; `return` is optional, and `using` directives may appear at the top of the snippet. | +| `--parameters` | object | - | Shell-quoted JSON object literal for reusing a snippet with varying data or keeping values outside the code. Values are exposed as `parameters["param0"]`, `parameters["param1"]`, and so on. Omit for most snippets; never pass a JSON string value. | +| `--wait-for-domain-reload` | flag | - | Wait for Domain Reload recovery after snippets that intentionally trigger Unity script reload or import work. Omit for normal inspection and editor-state workflows. | +| `--yield-to-foreground-requests` | flag | - | Allow foreground requests to preempt this execution | + +CLI-only flag, accepted instead of a schema parameter: + - `--code-file `: Read the C# statements from a file instead of `--code`. Use this when the active shell or launcher cannot preserve inline code exactly. Exactly one of `--code` or `--code-file` is required; combining them is an error. -- `--parameters {}` (advanced, optional): Pass a shell-quoted JSON object literal 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. Do not pass a JSON string value such as `"{\"param0\":\"value\"}"`. -- `--wait-for-domain-reload` (optional): Wait for Domain Reload recovery after snippets that intentionally trigger Unity script reload or import work. Omit this for normal inspection and editor-state workflows. ## Code Rules diff --git a/.claude/skills/uloop-pause-point/SKILL.md b/.claude/skills/uloop-pause-point/SKILL.md index 48f1eef9c7..d68d90dfd2 100644 --- a/.claude/skills/uloop-pause-point/SKILL.md +++ b/.claude/skills/uloop-pause-point/SKILL.md @@ -16,7 +16,9 @@ Use this small loop for one representative frame you care about. No source edit uloop enable-pause-point --file Assets/Scripts/Enemy.cs --line 42 --timeout-seconds 30 --await --trigger "simulate-keyboard --action Press --key Space" ``` -`--trigger` runs a single uloop subcommand in-process only after the marker's arming is confirmed, so there is no arm-vs-input race and nothing needs to run in the background. The hit response additionally carries `TriggerResult` with the triggered command's own response (or, when the trigger was skipped, `Completed: false` and the reason in `Error`). The trigger string cannot name another pause-point wait (`await-pause-point`/`enable-pause-point`) and cannot pass `--project-path` — the enclosing command's project is used. `await-pause-point --id --trigger ...` accepts the same flag for a marker enabled earlier. Both commands also accept `--resume-play` — see Fast-Progressing Games. +Digit keys are `Digit0`-`Digit9` or `Numpad0`-`Numpad9` — bare `0`-`9` is rejected. + +Before writing a `--trigger` command that differs from the example, load the skill of the tool you are about to trigger. `--trigger` runs a single uloop subcommand in-process only after the marker's arming is confirmed, so the input cannot land before arming and nothing needs to run in the background. One race does remain: the marker itself can hit before the trigger executes (for example on a line that runs every frame), in which case the trigger is rejected because PlayMode is already paused and runs nothing — the hit response then carries `TriggerFailed: true` at the top level and a `Warning` explaining that no input reached the game (the triggered command's own response stays in `TriggerResult`), so do not treat such a hit as input-driven. If the trigger command itself is rejected before it runs — its argument parsing fails (`INVALID_ARGUMENT`) or the command name is unknown (`UNKNOWN_COMMAND`) — the wait is abandoned immediately with a `PAUSE_POINT_TRIGGER_FAILED` error instead of waiting out `--timeout-seconds`: the marker stays armed, a PlayMode resumed by `--resume-play` is paused again, and `Error.NextActions` carries the recovery commands — fix the trigger value and re-run the same command. The hit response additionally carries `TriggerResult` with the triggered command's own response (or, when the trigger was skipped, `Completed: false` and the reason in `Error`). The trigger string cannot name another pause-point wait (`await-pause-point`/`enable-pause-point`) and cannot pass `--project-path` — the enclosing command's project is used. `await-pause-point --id --trigger ...` accepts the same flag for a marker enabled earlier. Both commands also accept `--resume-play` — see Fast-Progressing Games. When the game reaches the line on its own, omit `--trigger`. Fall back to split steps only when the triggering action is not a single uloop command (several inputs in sequence, an external event): run `enable-pause-point` without `--await` in the foreground (its response returning is the arm confirmation), then start `uloop await-pause-point --id ` in the background, then send the inputs. Do not approximate arm-waiting with a fixed sleep after a backgrounded enable. @@ -30,6 +32,63 @@ The response returns the derived marker `Id` (`Assets/Scripts/Enemy.cs:42`), the A hit pauses Unity at the next frame boundary — the patched method and the rest of that frame still run to completion. Only `CapturedVariables` is evidence of the values at the patched line; state read after the pause (for example via `execute-dynamic-code`) may already have advanced past it. +## Parameters + +One skill covers several commands, so each command's schema parameters have their own table below. +CLI-only flags (`--await`, `--trigger`, `--resume-play`, `--expect`, `--captured-variables`, +`--captured-variable-names`, `--matching-logs-max-count`) are described in the sections above; only +parameters Unity itself accepts appear here. + +### enable-pause-point + +Enable a pause point so Unity pauses when that code path is reached, either by a named UloopPausePoint.Pause marker (Id) or by resolving a source file and line (File+Line) + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--id` | string | - | Named pause point id passed to UloopPausePoint.Pause. Mutually exclusive with File/Line | +| `--file` | string | - | Project-relative source file path to patch a pause point into. Requires Line; mutually exclusive with Id | +| `--line` | integer | - | 1-based source line to resolve within File. Requires File; mutually exclusive with Id | +| `--timeout-seconds` | integer | `30` | Seconds before the enable request expires and stops pausing late hits | +| `--mode` | enum | `single-shot` | Capture mode: single-shot pauses once, continuous pauses on every hit, trace records hits without pausing | +| `--max-history` | integer | `20` | Maximum number of captured hit frames to retain (1-100) | +| `--max-preview-elements` | integer | `10` | Maximum number of elements to include in a captured collection's preview (1-1000). The value set at enable time also caps the previews in every later pause-point-status response for that marker; status has no flag to change it. | + +### clear-pause-point + +Clear one or all named UloopPausePoint.Pause markers + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--id` | string | - | Named pause point id to clear | +| `--all` | flag | - | Clear every active pause point marker | + +### enable-watch + +Register a C# expression to evaluate on each paused Play Mode step + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--id` | string | - | Unique watch expression identifier | +| `--expression` | string | - | C# expression returning an object; UloopPausePoint.TryGetCapturedValue can read the latest raw capture | +| `--max-history` | integer | `20` | Maximum number of watch evaluations to retain (1-100) | + +### get-watch-values + +Show registered watch expression values and bounded evaluation history + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--id` | string | - | Optional watch expression identifier; omit to return all watches | + +### clear-watch + +Clear one or all registered C# watch expressions + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--id` | string | - | Watch expression identifier to clear | +| `--all` | flag | - | Clear every registered watch expression | + ## Capture Modes and History Choose the capture mode when enabling a pause point: @@ -51,9 +110,9 @@ Every hit response embeds `CapturedVariables`: the method's in-scope locals, its - The snapshot is taken **before** the resolved line executes, exactly like an IDE breakpoint on that line. To inspect a value after an assignment, place the pause point on the following line. - `Scope` is `Local`, `Parameter`, `InstanceField`, or `This`. The synthetic `this` entry identifies which instance or GameObject was hit via `UnityObjectPath` and `UnityObjectInstanceId`; `UnityEngine.Object` values carry the same handle fields for follow-up digs with `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code`. - `--captured-variables names` on `await-pause-point`/`pause-point-status` drops every `Value` and keeps `Name`/`Scope`/`TypeName` — use it first on field-heavy classes, then fetch full values with a plain `pause-point-status` call. -- When the response would be dominated by variables you do not need, pass `--captured-variable-names velocity,this` (comma-separated, exact match on `Name`) to keep only those entries; it composes with `--captured-variables full|names`. -- Pass `--expect 'name=value'` (repeatable; on `await-pause-point` and `enable-pause-point --await`, not `pause-point-status`) to have the CLI compare captured variables against expected values; the response includes an `Expectations` array and `AllExpectationsPassed`, so you do not need to eyeball the JSON. Matching is string equality against the serialized value. -- Collection values (arrays, `List`, dictionaries, plain objects) render as a JSON preview capped at 10 elements by default. When the elements you need sit past that cap (a 10x20 grid, a long list), re-enable with `--max-preview-elements ` (1–1000). +- When the response would be dominated by variables you do not need, pass `--captured-variable-names velocity,this` (comma-separated, exact match on `Name`) to keep only those entries; it composes with `--captured-variables full|names`. `CapturedVariablesTruncated` in the response reports truncation at Unity-side capture time and is unrelated to this name filter — it can be `true` even when every requested name was found. Requested names that matched nothing are listed in `CapturedVariableNamesNotFound`, so a partial match is visible without comparing the response against the request by hand. +- Pass `--expect 'name=value'` (repeatable; on `await-pause-point`, `enable-pause-point --await`, and `pause-point-status`) to have the CLI compare captured variables against expected values; the response includes an `Expectations` array and `AllExpectationsPassed`, so you do not need to eyeball the JSON. Matching is string equality against the serialized value. On `pause-point-status` a marker that has not been hit yet reports each expectation as not found, and the verdict never changes the exit code — a polling loop reads `AllExpectationsPassed`, not the exit status. +- Collection values (arrays, `List`, dictionaries, plain objects) render as a JSON preview capped at 10 elements by default. When the elements you need sit past that cap (a 10x20 grid, a long list), re-enable with `--max-preview-elements ` (1–1000). The value set at enable time also caps the previews in every later `pause-point-status` response for that marker — status has no flag to change it. - While Unity is still paused, `UloopPausePoint.TryGetCapturedValue("name")` (and `"this"`) returns live captured references for `execute-dynamic-code`; the return is a `(bool Found, object Value)` tuple, and the holder clears on resume. (file:line marker hits only — id-only markers store no capture) These are **live objects in their frame-completed state, not snapshots** — use them only to dig further into objects that are still alive, never to reconstruct what a value was at the paused line. For snapshot timing, preview/truncation caps, Unity-object `Value` semantics, capture-time vs live evidence, `Warning`/`MatchingLogs`, marker freshness, and the raw capture API, read [references/captured-variables.md](references/captured-variables.md). @@ -93,7 +152,7 @@ For "N frames after the input" (for example, three frames after a key press), ad A pause point hits only when control flow reaches the patched line (or the `Pause(id)` call). `simulate-keyboard` returning `PressEdgeObserved=true` means the input edge was observed, not that your target game logic has reached the pause line yet. -If a `simulate-*` command instead returns a failure whose message says PlayMode is paused, suspect a pause point hit rather than an unrelated failure: an active pause point can make PlayMode paused mid-simulation, and the `simulate-*` call surfaces that as a preflight failure. Check `uloop pause-point-status --id ` first to confirm the hit before treating it as a bug in the simulated action itself. +If a `simulate-*` command instead returns a failure whose message says PlayMode is paused, suspect a pause point hit rather than an unrelated failure: an active pause point can make PlayMode paused mid-simulation, and the `simulate-*` call surfaces that as a preflight failure. The failure response names the responsible marker in `RejectedByActivePausePointId`. Check `uloop pause-point-status --id ` first to confirm the hit before treating it as a bug in the simulated action itself. ## When To Use @@ -116,9 +175,11 @@ When the game advances on its own (timers, gravity, spawners), any state you arr ```bash uloop enable-pause-point --file Assets/Scripts/Enemy.cs --line 42 --timeout-seconds 60 \ - --await --resume-play --trigger "simulate-keyboard --action Press --key Space" + --await --resume-play --trigger "simulate-keyboard --action Press --key Digit3" ``` +Digit keys are `Digit0`-`Digit9` or `Numpad0`-`Numpad9` — bare `0`-`9` is rejected. + `--resume-play` (requires `--await`; `await-pause-point` accepts it too) resumes a paused PlayMode after the marker's arming is confirmed and before `--trigger` is dispatched. Size `--timeout-seconds` generously when arming while paused: a manual Pause does not freeze the marker countdown (see Timeout Checks). For `ResumePlayResult` semantics, why `Time.timeScale = 0` is not a substitute for pausing, the residual post-resume race, and why direct state writes may not stick while paused, read [references/fast-progressing-games.md](references/fast-progressing-games.md). diff --git a/.claude/skills/uloop-pause-point/references/captured-variables.md b/.claude/skills/uloop-pause-point/references/captured-variables.md index e19809a8cc..6588496374 100644 --- a/.claude/skills/uloop-pause-point/references/captured-variables.md +++ b/.claude/skills/uloop-pause-point/references/captured-variables.md @@ -78,6 +78,7 @@ return value; ``` The references are live objects in their frame-completed state: anything the hit's method changed — or destroyed — after the patched line is already applied, so a captured object can read as destroyed/null here even though `CapturedVariables` shows its pre-line field values intact. +Concrete example: with a marker on the line right before `Destroy(obj)`, `TryGetCapturedValue("obj")` returns the frame-completed object — already destroyed — while the pre-destroy field values are still readable in `CapturedVariables`. The holder clears when Unity resumes (not when you `Step` while still paused), when the matching pause point is cleared, when a new hit replaces the snapshot, or when PlayMode exits. After resume, `TryGetCapturedValue` returns `Found=false`. Re-enabling the same pause point while still paused (for example to refresh its timeout during a step session) keeps the held references, because a re-enable does not resume Unity. diff --git a/.claude/skills/uloop-pause-point/references/fast-progressing-games.md b/.claude/skills/uloop-pause-point/references/fast-progressing-games.md index a7b0c300a6..60017b8600 100644 --- a/.claude/skills/uloop-pause-point/references/fast-progressing-games.md +++ b/.claude/skills/uloop-pause-point/references/fast-progressing-games.md @@ -15,12 +15,14 @@ uloop execute-dynamic-code --code '...' # 3) One call: confirm the marker armed, resume PlayMode, fire the input, await the hit uloop enable-pause-point --file Assets/Scripts/Enemy.cs --line 42 --timeout-seconds 60 \ - --await --resume-play --trigger "simulate-keyboard --action Press --key Space" + --await --resume-play --trigger "simulate-keyboard --action Press --key Digit3" ``` +Digit keys are `Digit0`-`Digit9` or `Numpad0`-`Numpad9` — bare `0`-`9` is rejected. + ## --resume-play Semantics -`--resume-play` runs after the marker's arming is confirmed and before `--trigger` is dispatched: it resumes PlayMode only when PlayMode is actually paused, and reports what it did in `ResumePlayResult` (`WasPaused` / `Resumed` / `Error`). If the resume fails, the trigger is not dispatched and `TriggerResult.Error` says so. When the game reaches the line on its own after resuming (gravity, physics), omit `--trigger` and keep `--resume-play`. +`--resume-play` runs after the marker's arming is confirmed and before `--trigger` is dispatched: it resumes PlayMode only when PlayMode is actually paused, and reports what it did in `ResumePlayResult` (`WasPaused` / `Resumed` / `Error`; an abandoned wait adds `Repaused` / `RepauseError`). If the resume fails, the trigger is not dispatched and `TriggerResult.Error` says so. If the trigger itself is rejected before it runs, the wait is abandoned and the resume is undone: `Repaused: true` (or `RepauseError`) reports PlayMode being paused again, so gameplay cannot consume the preserved marker while the trigger value is being fixed. When the game reaches the line on its own after resuming (gravity, physics), omit `--trigger` and keep `--resume-play`. ## Do Not Use Time.timeScale = 0 diff --git a/.claude/skills/uloop-record-input/SKILL.md b/.claude/skills/uloop-record-input/SKILL.md index 43f966dc3d..d3481797b3 100644 --- a/.claude/skills/uloop-record-input/SKILL.md +++ b/.claude/skills/uloop-record-input/SKILL.md @@ -28,9 +28,11 @@ uloop record-input --action Stop --output-path scripts/my-play.json | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | enum | `Start` | `Start` - begin recording, `Stop` - stop and save | -| `--output-path` | string | auto | Save path. Auto-generates under `.uloop/outputs/InputRecordings/` | -| `--keys` | string | `""` | Comma-separated key filter. Empty = all common game keys | +| `--action` | enum | `Start` | `Start` - begin recording input, `Stop` - stop recording and save to file | +| `--output-path` | string | auto | Save path for the recording JSON. When empty, auto-generates under `.uloop/outputs/InputRecordings/` | +| `--keys` | string | `""` | Comma-separated key filter of Input System Key enum names (for example `W,A,S,D,Space`). Case-insensitive. Digit keys use `Digit0`-`Digit9` or `Numpad0`-`Numpad9`, not bare `0`-`9`; a name that matches no key fails the command instead of being dropped from the filter. Empty records all common game keys | +| `--delay-seconds` | integer | `3` | Countdown delay in seconds before recording starts (0-10). Gives time to switch focus to Game View. | +| `--no-show-overlay` | flag | - | Hide the recording countdown and REC indicator overlay | ## Deterministic Replay diff --git a/.claude/skills/uloop-replay-input/SKILL.md b/.claude/skills/uloop-replay-input/SKILL.md index 051b96759c..1ae5450c37 100644 --- a/.claude/skills/uloop-replay-input/SKILL.md +++ b/.claude/skills/uloop-replay-input/SKILL.md @@ -31,8 +31,8 @@ uloop replay-input --action Stop | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | enum | `Start` | `Start`, `Stop`, `Status` | -| `--input-path` | string | auto | JSON path. Auto-detects latest in `.uloop/outputs/InputRecordings/` | +| `--action` | enum | `Start` | `Start` - begin replaying, `Stop` - stop mid-way, `Status` - check progress | +| `--input-path` | string | auto | Path to the recording JSON. When empty, auto-detects the latest recording in `.uloop/outputs/InputRecordings/` | | `--no-show-overlay` | flag | - | Hide replay progress overlay | | `--loop` | flag | - | Loop continuously | diff --git a/.claude/skills/uloop-screenshot/SKILL.md b/.claude/skills/uloop-screenshot/SKILL.md index 942de5c6cf..435880c53b 100644 --- a/.claude/skills/uloop-screenshot/SKILL.md +++ b/.claude/skills/uloop-screenshot/SKILL.md @@ -18,12 +18,12 @@ uloop screenshot [--window-name ] [--resolution-scale ] [--match-mo | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--window-name` | string | `Game` | Window name to capture. Ignored when `--capture-mode rendering`. When the Game tab is Device Simulator and the title is `Simulator`, default `Game` falls back to `Simulator`. | +| `--window-name` | string | `Game` | Window name to capture (for example `Game`, `Scene`, `Console`, `Inspector`). Ignored when `--capture-mode rendering`. When the Game tab is Device Simulator and the title is Simulator, default Game falls back to Simulator. | | `--resolution-scale` | number | `1.0` | Resolution scale (0.1 to 1.0) | | `--match-mode` | enum | `exact` | Window name matching mode: `exact`, `prefix`, or `contains`. Ignored when `--capture-mode rendering`. | -| `--capture-mode` | enum | `window` | `window`=capture EditorWindow including toolbar, `rendering`=capture game rendering only (PlayMode required, coordinates match simulate-mouse) | +| `--capture-mode` | enum | `window` | `window` - capture EditorWindow including toolbar, `rendering` - capture game rendering only (PlayMode required). Rendering screenshots return `ScreenshotToInputFormula` for converting raw image pixels before calling simulate-mouse-input or raycast. | | `--output-directory` | string | `""` | Output directory path for saving screenshots. When empty, uses default path (.uloop/outputs/Screenshots/). Accepts absolute paths. | -| `--annotate-elements` | flag | - | Annotate interactive UI elements with index labels and interaction hints (A / CLICK, B / DRAG, ...). Only works with `--capture-mode rendering` in PlayMode. | +| `--annotate-elements` | flag | - | Annotate interactive UI elements with index labels and interaction hints (A / CLICK, B / DRAG, ...). The response includes an `AnnotatedElements` array with element metadata sorted by z-order. Only works with `--capture-mode rendering` in PlayMode. | | `--annotate-raycast-grid` | flag | - | Annotate clustered 3D physics collider candidates as `PhysicsCollider` entries in `AnnotatedElements`. Uses `Camera.main` visibility and the same top-left Game View coordinates as `simulate-mouse-input`. Only works with `--capture-mode rendering` in PlayMode. | | `--raycast-layer-mask` | string | `""` | Comma-separated physics layer names to narrow which layers `--annotate-raycast-grid` clusters. Hits are limited to layers also visible to `Camera.main.cullingMask`. When omitted, clusters against `Physics.DefaultRaycastLayers`. | | `--elements-only` | flag | - | Return only annotated element JSON without capturing a screenshot image. Requires `--annotate-elements` or `--annotate-raycast-grid`, and `--capture-mode rendering` in PlayMode. | diff --git a/.claude/skills/uloop-simulate-keyboard/SKILL.md b/.claude/skills/uloop-simulate-keyboard/SKILL.md index c3e601ce69..fd8b1887ab 100644 --- a/.claude/skills/uloop-simulate-keyboard/SKILL.md +++ b/.claude/skills/uloop-simulate-keyboard/SKILL.md @@ -1,7 +1,7 @@ --- name: uloop-simulate-keyboard toolName: simulate-keyboard -description: "Simulate keyboard input in PlayMode through Unity Input System. Use for key presses, holds, releases, and game controls such as WASD or Space." +description: "Simulate keyboard input in PlayMode through Unity Input System. Use for key presses, holds (via Press --duration or KeyDown/KeyUp), releases, and game controls such as WASD or Space. Requires the Input System package (com.unity.inputsystem)." --- # Task @@ -27,7 +27,7 @@ uloop simulate-keyboard --action ReleaseAll | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | enum | `Press` | `Press`, `KeyDown`, `KeyUp`, `ReleaseAll` | +| `--action` | enum | `Press` | `Press` - one-shot key tap (Down then Up), `KeyDown` - hold key down, `KeyUp` - release held key, `ReleaseAll` - force-release every tracked and device-pressed key (allowed while PlayMode is paused; use after a pause-point interruption leaves key state inconsistent) | | `--key` | string | (required except `ReleaseAll`) | Key name matching Input System Key enum (e.g. `W`, `Space`, `LeftShift`, `A`, `Enter`). Case-insensitive. Digit keys use `Digit0`-`Digit9` or `Numpad0`-`Numpad9`, not bare `0`-`9`. Not used by `ReleaseAll`. | | `--duration` | number | `0` | Hold duration in seconds for Press action (0 = one-shot tap). Ignored by KeyDown/KeyUp/ReleaseAll. | diff --git a/.claude/skills/uloop-simulate-mouse-input/SKILL.md b/.claude/skills/uloop-simulate-mouse-input/SKILL.md index a4029fe698..cc6a406c73 100644 --- a/.claude/skills/uloop-simulate-mouse-input/SKILL.md +++ b/.claude/skills/uloop-simulate-mouse-input/SKILL.md @@ -1,7 +1,7 @@ --- name: uloop-simulate-mouse-input toolName: simulate-mouse-input -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." +description: "Simulate Mouse.current input in PlayMode through Unity Input System. Use for gameplay mouse clicks, long-press (LongPress), movement delta (MoveDelta/SmoothDelta), or scroll. Use simulate-mouse-ui for UI. Requires the Input System package and Active Input Handling set to 'Input System Package (New)' or 'Both'." --- # Task @@ -17,6 +17,11 @@ Simulate mouse input via Input System in Unity PlayMode. 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 +Two rules while verifying: + +- Do not touch the physical mouse or keyboard, and keep the OS pointer off the Unity window — real device input mixes into the same `Mouse.current` state this tool injects. +- Read the starting values you will assert against immediately before firing the input; a value measured earlier in the session may have changed. + ## Tool Reference ```bash @@ -27,7 +32,7 @@ uloop simulate-mouse-input --action [options] | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | enum | `Click` | `Click`, `LongPress`, `MoveDelta`, `SmoothDelta`, `Scroll` | +| `--action` | enum | `Click` | `Click` - inject button press+release, `LongPress` - inject button hold for `--duration` seconds, `MoveDelta` - inject mouse delta (one-shot), `SmoothDelta` - inject mouse delta smoothly over `--duration` seconds, `Scroll` - inject scroll wheel | | `--x` | number | `0` | Target X position in Game View pixels (origin: top-left). Used by Click and LongPress. Use `AnnotatedElements[].SimX`, or raw image pixels converted with `ScreenshotToInputFormula`. | | `--y` | number | `0` | Target Y position in Game View pixels (origin: top-left). Used by Click and LongPress. Use `AnnotatedElements[].SimY`, or raw image pixels converted with `ScreenshotToInputFormula`. | | `--button` | enum | `Left` | Mouse button: `Left`, `Right`, `Middle`. Used by Click and LongPress. | @@ -35,7 +40,7 @@ uloop simulate-mouse-input --action [options] | `--delta-x` | number | `0` | Delta X in pixels for MoveDelta/SmoothDelta. Positive = right. | | `--delta-y` | number | `0` | Delta Y in pixels for MoveDelta/SmoothDelta. Positive = up. | | `--scroll-x` | number | `0` | Horizontal scroll delta for Scroll action. | -| `--scroll-y` | number | `0` | Vertical scroll delta for Scroll action. Typically 120 per notch. | +| `--scroll-y` | number | `0` | Vertical scroll delta for Scroll action. Positive = up, negative = down. Typically 120 per notch. | ### Actions diff --git a/.claude/skills/uloop-simulate-mouse-ui/SKILL.md b/.claude/skills/uloop-simulate-mouse-ui/SKILL.md index b317e85619..0a187f0c03 100644 --- a/.claude/skills/uloop-simulate-mouse-ui/SKILL.md +++ b/.claude/skills/uloop-simulate-mouse-ui/SKILL.md @@ -28,11 +28,11 @@ uloop simulate-mouse-ui --action --x --y [options] | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | enum | `Click` | `Click`, `Drag`, `DragStart`, `DragMove`, `DragEnd`, `LongPress` | +| `--action` | enum | `Click` | `Click` - click at position, `Drag` - one-shot drag, `DragStart` - begin drag and hold, `DragMove` - move while holding drag, `DragEnd` - release drag, `LongPress` - press and hold for `--duration` seconds | | `--x` | number | `0` | Target X position in screen pixels (origin: top-left). For Drag action, this is the destination. | | `--y` | number | `0` | Target Y position in screen pixels (origin: top-left). For Drag action, this is the destination. | -| `--from-x` | number | `0` | Start X position for Drag action. Drag starts here and moves to x,y. | -| `--from-y` | number | `0` | Start Y position for Drag action. Drag starts here and moves to x,y. | +| `--from-x` | number | `0` | Start X position for Drag action (origin: top-left). Drag starts here and moves to `--x`,`--y`. | +| `--from-y` | number | `0` | Start Y position for Drag action (origin: top-left). Drag starts here and moves to `--x`,`--y`. | | `--drag-speed` | number | `2000` | Drag speed in pixels per second (0 for instant). 2000 is fast (default), 200 is slow enough to watch. Applies to Drag, DragMove, and DragEnd actions. | | `--duration` | number | `0.5` | Hold duration in seconds for LongPress action. | | `--button` | enum | `Left` | Mouse button. `Click` and `LongPress` support `Left`, `Right`, and `Middle`. Drag actions support `Left` only; other buttons return an error. | diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 13119feec3..6b93d778a8 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -95,6 +95,10 @@ jobs: working-directory: cli/release-automation run: go run ./cmd/check-release-triggers --base "origin/${{ github.base_ref }}" --head HEAD + - name: Check tool documentation drift + working-directory: cli/release-automation + run: go run ./cmd/sync-tool-docs --check --repository-root "$GITHUB_WORKSPACE" + - name: Install golangci-lint run: | go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.0 diff --git a/.husky/pre-commit b/.husky/pre-commit index 7f18eb4158..4c71c770c9 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,7 +1,51 @@ #!/usr/bin/env sh +# Fast layers only. The full check is scripts/check-go-cli.sh, which PR preparation and CI run; +# hooks stay in the seconds range so committing never waits on lint, tests, or a binary rebuild. changed_cli_go="$(git diff --cached --name-only --diff-filter=ACMR | grep -E '^cli/.*\.go$' || true)" if [ -n "$changed_cli_go" ]; then - scripts/check-go-cli.sh + unformatted="$(gofmt -l $changed_cli_go)" + if [ -n "$unformatted" ]; then + echo "gofmt reports unformatted files:" >&2 + echo "$unformatted" >&2 + exit 1 + fi + + for module in cli/common cli/dispatcher cli/project-runner cli/release-automation; do + case "$changed_cli_go" in + *"$module/"*) + (cd "$module" && go vet ./...) || exit 1 + ;; + esac + done +fi + +changed_skill_md="$(git diff --cached --name-only --diff-filter=ACMR \ + | grep -E '^Packages/src/Editor/(FirstPartyTools|CliOnlyTools~)/[^/]+/Skill/SKILL\.md$' || true)" + +if [ -n "$changed_skill_md" ]; then + # The generator reads the working tree, so a partially staged skill file would produce a catalog + # describing text this commit does not contain. Stop instead of committing that disagreement. + for skill_file in $changed_skill_md; do + if [ -n "$(git diff --name-only -- "$skill_file")" ]; then + echo "$skill_file is only partially staged." >&2 + echo "The tool catalog is generated from the working tree, so stage the file fully or unstage it." >&2 + exit 1 + fi + done + + # Regenerating overwrites the catalog, so an unstaged edit to it would be swept into this commit as + # if it had been generated. Hand edits to it are discarded by the next run either way; say so now. + if [ -n "$(git diff --name-only -- cli/common/tools/default-tools.json)" ]; then + echo "cli/common/tools/default-tools.json has unstaged changes." >&2 + echo "It is generated from the skill parameter tables: stage a regenerated catalog, or discard the" >&2 + echo "changes with git restore, then commit again." >&2 + exit 1 + fi + + # The catalog is generated from these tables, so regenerate it here instead of letting CI reject the + # push. A table that disagrees with its schema fails the generator, which blocks the commit. + scripts/sync-tool-docs.sh || exit 1 + git add cli/common/tools/default-tools.json fi diff --git a/AGENTS.md b/AGENTS.md index 45301ec4a2..1249e0bc2d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,6 +64,22 @@ These files are generated copies. Update the source skill definitions instead, t - Sources: `Packages/src/Editor/FirstPartyTools//Skill/SKILL.md` and `Packages/src/Editor/CliOnlyTools~//Skill/SKILL.md` (plus each skill's `references/` files, which are copied along with it). - Regenerate: `dist/darwin-arm64/uloop skills install --claude --agents` from the project root, substituting the binary for your platform (e.g. `dist/windows-amd64/uloop.exe` on Windows). Only `.claude/` and `.agents/` are tracked in git; other targets are local-only. +## Generated Tool Catalog + +`cli/common/tools/default-tools.json` is generated from the skill parameter tables — it is what +`--help` and `uloop list` print when no project cache is available, and its descriptions must never +be hand-edited. When you change a parameter table or a tool description in +`Packages/src/Editor/FirstPartyTools//Skill/SKILL.md` or +`Packages/src/Editor/CliOnlyTools~//Skill/SKILL.md`, run `scripts/sync-tool-docs.sh` and +include the regenerated catalog in the same commit. `go run ./cmd/sync-tool-docs --check` in +`cli/release-automation` reports drift without writing, and CI runs it. + +Generation fails when a table and the schema disagree: a visible option with no row, or a row +matching no accepted option. Fix the table or the schema — do not work around the generator. + +Enable the repository hooks once per clone with `git config core.hooksPath .husky`; the pre-commit +hook then regenerates the catalog for you when a skill file is staged. + ## CI Automation Language Write GitHub Actions and release automation logic in Go when it needs JSON parsing, workflow polling, state transitions, or non-trivial branching. @@ -128,6 +144,14 @@ dist/darwin-arm64/uloop compile --project-path "$(git rev-parse --show-toplevel) Substitute the binary for your platform (e.g. `dist/windows-amd64/uloop.exe` on Windows). +When an AI agent runs these dev-binary commands through a sandboxed shell, Unity IPC over the +Unix socket is denied with EPERM even though plain `uloop ...` may appear to work: the sandbox +exclusion is matched against the command text, and which command shapes survive that matching +is not guessable from the outside. Read `docs/claude-code-sandbox.md` *before* running +dev-binary commands in that setting, not only once a "Unity not reachable" symptom appears. A +CLI predating the refusal report turns the same denial into an `i/o timeout`, which is what +made this cost a full investigation. + Before running a command with `--project-path`, confirm the path is the intended Unity project for the current task — do not copy a sibling checkout path from another repository or session. @@ -138,7 +162,11 @@ to refresh `dist` binaries; they are git-ignored and must not be committed. To validate an unreleased project runner from an external Unity project, set the `ULOOP_PROJECT_RUNNER_PATH` environment variable to a locally built binary — it overrides the -pin-based resolution entirely (see `docs/project-runner-pin.md`). +pin-based resolution entirely (see `docs/project-runner-pin.md`). Inside this checkout the +variable is not what makes a run use your build: `dist//uloop` already takes the +runner built beside it, and no response field says which runner served a command. Read "Which +runner actually ran" in that document before concluding that a runner-side change does or does +not work. ## Unity Freeze Prevention diff --git a/Assets/Tests/Editor/AlwaysEnabledToolSettingsPort.cs b/Assets/Tests/Editor/AlwaysEnabledToolSettingsPort.cs new file mode 100644 index 0000000000..55c181fbb4 --- /dev/null +++ b/Assets/Tests/Editor/AlwaysEnabledToolSettingsPort.cs @@ -0,0 +1,32 @@ +using System; + +using io.github.hatayama.UnityCliLoop.Domain; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Test-only settings port that exposes every discovered tool. + /// Guard tests compare the whole catalog, so local tool settings must not hide disabled tools and + /// turn a developer's preferences into a failure. + /// + internal sealed class AlwaysEnabledToolSettingsPort : IToolSettingsPort + { + public bool IsToolEnabled(string toolName) + { + return true; + } + + public void SetToolEnabled(string toolName, bool enabled) + { + } + + public string[] GetDisabledTools() + { + return Array.Empty(); + } + + public void InvalidateCache() + { + } + } +} diff --git a/Assets/Tests/Editor/AlwaysEnabledToolSettingsPort.cs.meta b/Assets/Tests/Editor/AlwaysEnabledToolSettingsPort.cs.meta new file mode 100644 index 0000000000..faef3bc00a --- /dev/null +++ b/Assets/Tests/Editor/AlwaysEnabledToolSettingsPort.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1337ab45dda9045f5a60a36cfbf8ca98 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/DefaultToolsCatalogDriftTests.cs b/Assets/Tests/Editor/DefaultToolsCatalogDriftTests.cs index fd6d86afba..5598f3e849 100644 --- a/Assets/Tests/Editor/DefaultToolsCatalogDriftTests.cs +++ b/Assets/Tests/Editor/DefaultToolsCatalogDriftTests.cs @@ -231,29 +231,5 @@ private static JObject RemoveEmbeddedOnlyEnums(JObject embeddedSchema, JObject l return comparableSchema; } - - /// - /// Test-only settings port that exposes every discovered tool. - /// - private sealed class AlwaysEnabledToolSettingsPort : IToolSettingsPort - { - public bool IsToolEnabled(string toolName) - { - return true; - } - - public void SetToolEnabled(string toolName, bool enabled) - { - } - - public string[] GetDisabledTools() - { - return Array.Empty(); - } - - public void InvalidateCache() - { - } - } } } diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/FirstPartyToolSchemaMetadataTests.cs b/Assets/Tests/Editor/DynamicCodeToolTests/FirstPartyToolSchemaMetadataTests.cs index 776ef8c127..a5a87472b0 100644 --- a/Assets/Tests/Editor/DynamicCodeToolTests/FirstPartyToolSchemaMetadataTests.cs +++ b/Assets/Tests/Editor/DynamicCodeToolTests/FirstPartyToolSchemaMetadataTests.cs @@ -20,11 +20,7 @@ public class FirstPartyToolSchemaMetadataTests public void FirstPartySchemaProperties_WhenLoaded_ShouldNotExposeDescriptionAttributes() { // Tests that long-form agent guidance stays in skill files instead of runtime schema metadata. - Type[] schemaTypes = TypeCache.GetTypesDerivedFrom() - .Where(type => type.Assembly.GetName().Name.StartsWith( - "UnityCLILoop.FirstPartyTools", - StringComparison.Ordinal)) - .ToArray(); + Type[] schemaTypes = FirstPartySchemaTypes(); Assert.That(schemaTypes, Is.Not.Empty); @@ -45,6 +41,69 @@ public void FirstPartySchemaProperties_WhenLoaded_ShouldNotExposeDescriptionAttr } } + [Test] + public void FirstPartySchemaEnumProperties_WhenLoaded_ShouldBeZeroBasedAndContiguous() + { + // Tests that every enum a first-party schema exposes can be resolved by its ordinal. + // The schema cache stores an enum default as a number while listing the members by name, + // so the CLI recovers the name shown in `--help` by indexing the name list with that + // number. A member with an explicit value or a [Flags] enum would make the CLI print a + // different member's name as the default. + Type[] schemaTypes = FirstPartySchemaTypes(); + + Assert.That(schemaTypes, Is.Not.Empty); + + int checkedEnumPropertyCount = 0; + foreach (Type schemaType in schemaTypes) + { + PropertyInfo[] properties = schemaType.GetProperties( + BindingFlags.Instance | + BindingFlags.Public | + BindingFlags.DeclaredOnly); + + foreach (PropertyInfo property in properties) + { + Type propertyType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType; + if (!propertyType.IsEnum) + { + continue; + } + + string location = $"{schemaType.FullName}.{property.Name} ({propertyType.FullName})"; + + Assert.That( + propertyType.GetCustomAttribute(), + Is.Null, + $"{location} is a [Flags] enum, which cannot be resolved by ordinal"); + + Array members = Enum.GetValues(propertyType); + for (int index = 0; index < members.Length; index++) + { + long value = Convert.ToInt64(members.GetValue(index)); + Assert.That( + value, + Is.EqualTo((long)index), + $"{location} is not zero-based and contiguous at index {index}"); + } + + checkedEnumPropertyCount++; + } + } + + // Guards the guard: if schemas stop exposing enums the assertions above go unreached, + // and this test would keep passing while checking nothing. + Assert.That(checkedEnumPropertyCount, Is.GreaterThan(0)); + } + + private static Type[] FirstPartySchemaTypes() + { + return TypeCache.GetTypesDerivedFrom() + .Where(type => type.Assembly.GetName().Name.StartsWith( + "UnityCLILoop.FirstPartyTools", + StringComparison.Ordinal)) + .ToArray(); + } + [Test] public void ExecuteDynamicCodeSchema_WhenCreated_ShouldNotWaitForDomainReloadByDefault() { diff --git a/Assets/Tests/Editor/InputRecordingKeyFilterTests.cs b/Assets/Tests/Editor/InputRecordingKeyFilterTests.cs new file mode 100644 index 0000000000..f62b1bdbce --- /dev/null +++ b/Assets/Tests/Editor/InputRecordingKeyFilterTests.cs @@ -0,0 +1,91 @@ +#if ULOOP_HAS_INPUT_SYSTEM +using System.Collections.Generic; +using NUnit.Framework; +using UnityEngine.InputSystem; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Test fixture that verifies the record-input key filter accepts only defined key names. + /// + public sealed class InputRecordingKeyFilterTests + { + /// + /// Tests that named keys are accepted case-insensitively and no name is reported invalid. + /// + [Test] + public void ParseKeyFilter_WhenGivenKeyNames_KeepsThemAll() + { + KeyFilterParseResult result = InputRecordingFileHelper.ParseKeyFilter("space, w"); + + Assert.IsEmpty(result.InvalidKeyNames); + Assert.IsNotNull(result.Filter); + Assert.AreEqual(new HashSet { Key.Space, Key.W }, result.Filter); + } + + /// + /// Tests that a bare digit is reported invalid instead of silently filtering the key whose + /// enum ordinal it happens to be. + /// + [Test] + public void ParseKeyFilter_WhenGivenAnOrdinal_ReportsItInvalid() + { + KeyFilterParseResult result = InputRecordingFileHelper.ParseKeyFilter("3"); + + Assert.AreEqual(new[] { "3" }, result.InvalidKeyNames); + Assert.IsNull(result.Filter); + } + + /// + /// Tests that an invalid entry alongside a valid one is still reported, so a partially + /// applied filter is never mistaken for the requested one. + /// + [Test] + public void ParseKeyFilter_WhenOneEntryIsInvalid_ReportsThatEntry() + { + KeyFilterParseResult result = InputRecordingFileHelper.ParseKeyFilter("W, 3"); + + Assert.AreEqual(new[] { "3" }, result.InvalidKeyNames); + } + + /// + /// Tests that a filter made only of empty entries is reported invalid: it would otherwise + /// record every key while looking like no filter was requested. + /// + [Test] + public void ParseKeyFilter_WhenEveryEntryIsEmpty_ReportsTheRawInputInvalid() + { + KeyFilterParseResult result = InputRecordingFileHelper.ParseKeyFilter(", ,"); + + Assert.AreEqual(new[] { ", ," }, result.InvalidKeyNames); + Assert.IsNull(result.Filter); + } + + /// + /// Tests that a trailing comma is harmless once another entry names a key. + /// + [Test] + public void ParseKeyFilter_WhenAnEntryIsEmptyBesideANamedKey_KeepsTheKey() + { + KeyFilterParseResult result = InputRecordingFileHelper.ParseKeyFilter("W,"); + + Assert.IsEmpty(result.InvalidKeyNames); + Assert.AreEqual(new HashSet { Key.W }, result.Filter); + } + + /// + /// Tests that no filter and no invalid name is reported when the parameter is omitted. + /// + [Test] + public void ParseKeyFilter_WhenGivenNothing_ReportsNoFilter() + { + KeyFilterParseResult result = InputRecordingFileHelper.ParseKeyFilter(""); + + Assert.IsEmpty(result.InvalidKeyNames); + Assert.IsNull(result.Filter); + } + } +} +#endif diff --git a/Assets/Tests/Editor/InputRecordingKeyFilterTests.cs.meta b/Assets/Tests/Editor/InputRecordingKeyFilterTests.cs.meta new file mode 100644 index 0000000000..fe4c257054 --- /dev/null +++ b/Assets/Tests/Editor/InputRecordingKeyFilterTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8afa56a901fe140609ebf4a9c0e8a193 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/KeyNameResolverTests.cs b/Assets/Tests/Editor/KeyNameResolverTests.cs new file mode 100644 index 0000000000..c4fe5df597 --- /dev/null +++ b/Assets/Tests/Editor/KeyNameResolverTests.cs @@ -0,0 +1,111 @@ +#if ULOOP_HAS_INPUT_SYSTEM +using NUnit.Framework; +using UnityEngine.InputSystem; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Test fixture that verifies key names resolve only through defined Key enum names. + /// + public sealed class KeyNameResolverTests + { + /// + /// Tests that a defined key name resolves regardless of the casing it was written in. + /// + [Test] + public void Resolve_WhenGivenADefinedNameInAnyCasing_ResolvesTheKey() + { + (bool resolved, Key key) = KeyNameResolver.Resolve("space"); + + Assert.IsTrue(resolved); + Assert.AreEqual(Key.Space, key); + } + + /// + /// Tests that surrounding whitespace does not stop a defined name from resolving. + /// + [Test] + public void Resolve_WhenGivenAPaddedName_ResolvesTheKey() + { + (bool resolved, Key key) = KeyNameResolver.Resolve(" W "); + + Assert.IsTrue(resolved); + Assert.AreEqual(Key.W, key); + } + + /// + /// Tests that "Return" keeps resolving to Enter, the alias callers already relied on. + /// + [Test] + public void Resolve_WhenGivenTheReturnAlias_ResolvesEnter() + { + (bool resolved, Key key) = KeyNameResolver.Resolve("Return"); + + Assert.IsTrue(resolved); + Assert.AreEqual(Key.Enter, key); + } + + /// + /// Tests that a bare digit is rejected instead of being read as an enum ordinal. + /// + [Test] + public void Resolve_WhenGivenAnOrdinal_IsRejected() + { + (bool resolved, Key key) = KeyNameResolver.Resolve("3"); + + Assert.IsFalse(resolved); + Assert.AreEqual(Key.None, key); + } + + /// + /// Tests that a signed ordinal is rejected: Enum.TryParse accepted it as a value too. + /// + [Test] + public void Resolve_WhenGivenASignedOrdinal_IsRejected() + { + (bool resolved, Key key) = KeyNameResolver.Resolve("-1"); + + Assert.IsFalse(resolved); + Assert.AreEqual(Key.None, key); + } + + /// + /// Tests that an ordinal outside the enum is rejected rather than producing an undefined key. + /// + [Test] + public void Resolve_WhenGivenAnUndefinedOrdinal_IsRejected() + { + (bool resolved, Key key) = KeyNameResolver.Resolve("300"); + + Assert.IsFalse(resolved); + Assert.AreEqual(Key.None, key); + } + + /// + /// Tests that comma-separated names are rejected instead of being OR-ed into one value. + /// + [Test] + public void Resolve_WhenGivenCommaSeparatedNames_IsRejected() + { + (bool resolved, Key key) = KeyNameResolver.Resolve("Space,Enter"); + + Assert.IsFalse(resolved); + Assert.AreEqual(Key.None, key); + } + + /// + /// Tests that the placeholder None value is rejected: it names no physical key. + /// + [Test] + public void Resolve_WhenGivenNone_IsRejected() + { + (bool resolved, Key key) = KeyNameResolver.Resolve("None"); + + Assert.IsFalse(resolved); + Assert.AreEqual(Key.None, key); + } + } +} +#endif diff --git a/Assets/Tests/Editor/KeyNameResolverTests.cs.meta b/Assets/Tests/Editor/KeyNameResolverTests.cs.meta new file mode 100644 index 0000000000..1a6fa3479f --- /dev/null +++ b/Assets/Tests/Editor/KeyNameResolverTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b1157c682c6684c189f34872ba911a8a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/KeyboardKeyNameSuggesterTests.cs b/Assets/Tests/Editor/KeyboardKeyNameSuggesterTests.cs index 3e3fe00ac9..d5ff020b04 100644 --- a/Assets/Tests/Editor/KeyboardKeyNameSuggesterTests.cs +++ b/Assets/Tests/Editor/KeyboardKeyNameSuggesterTests.cs @@ -19,6 +19,17 @@ public void Suggest_ForBareDigit_IncludesDigitAndNumpadNames() Assert.That(suggestions, Does.Contain("Numpad3")); } + // Verifies non-ASCII digits do not produce Digit/Numpad names that no Key enum value has. + [Test] + public void Suggest_ForFullWidthDigit_DoesNotSuggestDigitNames() + { + IReadOnlyList suggestions = KeyboardKeyNameSuggester.Suggest("3"); + + Assert.That(suggestions, Does.Not.Contain("Digit3")); + Assert.That(suggestions, Does.Not.Contain("Numpad3")); + Assert.That(suggestions.Any(name => name.StartsWith("Digit")), Is.False); + } + // Verifies partial key names still return close enum matches. [Test] public void Suggest_ForPartialName_ReturnsPrefixMatches() diff --git a/Assets/Tests/Editor/PausePointRejectionResponseFieldTests.cs b/Assets/Tests/Editor/PausePointRejectionResponseFieldTests.cs new file mode 100644 index 0000000000..f65719dadc --- /dev/null +++ b/Assets/Tests/Editor/PausePointRejectionResponseFieldTests.cs @@ -0,0 +1,67 @@ +using Newtonsoft.Json; +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Test fixture that pins the wire name of the pause-point rejection field on every tool response + /// a --trigger can dispatch, because the CLI matches on that exact name to detect a trigger that + /// was refused before it ran. + /// + public class PausePointRejectionResponseFieldTests + { + private const string ExpectedJsonFragment = "\"RejectedByActivePausePointId\":\"marker\""; + + [Test] + public void SimulateKeyboardResponse_WhenRejectedByPausePoint_SerializesTheRejectionField() + { + // Verifies simulate-keyboard's rejection response carries the field under the name the CLI reads. + string json = JsonConvert.SerializeObject( + new SimulateKeyboardResponse { Success = false, RejectedByActivePausePointId = "marker" }); + + Assert.That(json, Does.Contain(ExpectedJsonFragment)); + } + + [Test] + public void SimulateMouseInputResponse_WhenRejectedByPausePoint_SerializesTheRejectionField() + { + // Verifies simulate-mouse-input's rejection response carries the field under the name the CLI reads. + string json = JsonConvert.SerializeObject( + new SimulateMouseInputResponse { Success = false, RejectedByActivePausePointId = "marker" }); + + Assert.That(json, Does.Contain(ExpectedJsonFragment)); + } + + [Test] + public void SimulateMouseUiResponse_WhenRejectedByPausePoint_SerializesTheRejectionField() + { + // Verifies simulate-mouse-ui's rejection response carries the field under the name the CLI reads. + string json = JsonConvert.SerializeObject( + new SimulateMouseUiResponse { Success = false, RejectedByActivePausePointId = "marker" }); + + Assert.That(json, Does.Contain(ExpectedJsonFragment)); + } + + [Test] + public void RecordInputResponse_WhenRejectedByPausePoint_SerializesTheRejectionField() + { + // Verifies record-input reports the same structured rejection, since it shares the preflight that refuses it. + string json = JsonConvert.SerializeObject( + new RecordInputResponse { Success = false, RejectedByActivePausePointId = "marker" }); + + Assert.That(json, Does.Contain(ExpectedJsonFragment)); + } + + [Test] + public void ReplayInputResponse_WhenRejectedByPausePoint_SerializesTheRejectionField() + { + // Verifies replay-input reports the same structured rejection: it is a realistic --trigger target. + string json = JsonConvert.SerializeObject( + new ReplayInputResponse { Success = false, RejectedByActivePausePointId = "marker" }); + + Assert.That(json, Does.Contain(ExpectedJsonFragment)); + } + } +} diff --git a/Assets/Tests/Editor/PausePointRejectionResponseFieldTests.cs.meta b/Assets/Tests/Editor/PausePointRejectionResponseFieldTests.cs.meta new file mode 100644 index 0000000000..4ac8bac88b --- /dev/null +++ b/Assets/Tests/Editor/PausePointRejectionResponseFieldTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 48fc9d8273cd94ee585a666e36a3e4e9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/PlayModeToolPreflightResultTests.cs b/Assets/Tests/Editor/PlayModeToolPreflightResultTests.cs new file mode 100644 index 0000000000..3a3dc95d3c --- /dev/null +++ b/Assets/Tests/Editor/PlayModeToolPreflightResultTests.cs @@ -0,0 +1,79 @@ +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Test fixture that verifies the preflight result carries the active pause point's id as a + /// structured field, not only inside the human-readable rejection message. + /// + public class PlayModeToolPreflightResultTests + { + private const string PausedActionDescription = "simulating keyboard input"; + + [Test] + public void Evaluate_WhenPlayModeIsNotActive_ReportsNoPausePointId() + { + // Verifies a not-active rejection has nothing to do with a pause point, so the structured field stays empty. + PlayModeToolPreflightResult result = PlayModeToolPreflightService.Evaluate( + isPlaying: false, + isPaused: false, + activePausePointId: "marker", + pausedActionDescription: PausedActionDescription); + + Assert.That(result.IsValid, Is.False); + Assert.That(result.ErrorMessage, Is.EqualTo(PlayModeToolPreflightService.PlayModeNotActiveMessage)); + Assert.That(result.RejectedByActivePausePointId, Is.Null); + } + + [Test] + public void Evaluate_WhenPausedByPausePoint_ReportsThatPausePointId() + { + // Verifies a pause-point-owned pause reports the id in a field a caller can compare, since the message alone forces string matching. + PlayModeToolPreflightResult result = PlayModeToolPreflightService.Evaluate( + isPlaying: true, + isPaused: true, + activePausePointId: "marker", + pausedActionDescription: PausedActionDescription); + + Assert.That(result.IsValid, Is.False); + Assert.That(result.RejectedByActivePausePointId, Is.EqualTo("marker")); + Assert.That( + result.ErrorMessage, + Is.EqualTo(PlayModeToolPreflightService.FormatPausePointPausedMessage("marker", PausedActionDescription))); + } + + [Test] + public void Evaluate_WhenPausedWithoutPausePoint_ReportsNoPausePointId() + { + // Verifies a manual pause is not attributed to a pause point, so a caller cannot mistake it for one of its own markers. + PlayModeToolPreflightResult result = PlayModeToolPreflightService.Evaluate( + isPlaying: true, + isPaused: true, + activePausePointId: string.Empty, + pausedActionDescription: PausedActionDescription); + + Assert.That(result.IsValid, Is.False); + Assert.That(result.RejectedByActivePausePointId, Is.Null); + Assert.That( + result.ErrorMessage, + Is.EqualTo(PlayModeToolPreflightService.FormatPausedMessage(PausedActionDescription))); + } + + [Test] + public void Evaluate_WhenPlayingAndNotPaused_Succeeds() + { + // Verifies the success path stays a plain success with no rejection details attached. + PlayModeToolPreflightResult result = PlayModeToolPreflightService.Evaluate( + isPlaying: true, + isPaused: false, + activePausePointId: "marker", + pausedActionDescription: PausedActionDescription); + + Assert.That(result.IsValid, Is.True); + Assert.That(result.ErrorMessage, Is.Empty); + Assert.That(result.RejectedByActivePausePointId, Is.Null); + } + } +} diff --git a/Assets/Tests/Editor/PlayModeToolPreflightResultTests.cs.meta b/Assets/Tests/Editor/PlayModeToolPreflightResultTests.cs.meta new file mode 100644 index 0000000000..3fa024dc77 --- /dev/null +++ b/Assets/Tests/Editor/PlayModeToolPreflightResultTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: eb33fa476a1a34b47ba86f1626768877 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/PlayModeToolPreflightServiceTests.cs b/Assets/Tests/Editor/PlayModeToolPreflightServiceTests.cs index b7b47dde9e..aa2b2e8599 100644 --- a/Assets/Tests/Editor/PlayModeToolPreflightServiceTests.cs +++ b/Assets/Tests/Editor/PlayModeToolPreflightServiceTests.cs @@ -1,7 +1,6 @@ using NUnit.Framework; using io.github.hatayama.UnityCliLoop.FirstPartyTools; -using io.github.hatayama.UnityCliLoop.ToolContracts; namespace io.github.hatayama.UnityCliLoop.Tests.Editor { @@ -17,7 +16,7 @@ public class PlayModeToolPreflightServiceTests public void RequireActive_WhenEditModeIsNotPlaying_ReturnsNotActiveFailure() { // Verifies the active-only preflight fails with the exact wire-visible not-active message. - ValidationResult result = PlayModeToolPreflightService.RequireActive(); + PlayModeToolPreflightResult result = PlayModeToolPreflightService.RequireActive(); Assert.That(result.IsValid, Is.False); Assert.That(result.ErrorMessage, Is.EqualTo(ExpectedNotActiveMessage)); @@ -27,7 +26,7 @@ public void RequireActive_WhenEditModeIsNotPlaying_ReturnsNotActiveFailure() public void RequireActiveAndNotPaused_WhenEditModeIsNotPlaying_ReturnsNotActiveFailure() { // Verifies the paused-aware preflight also fails with the exact not-active message when PlayMode is inactive. - ValidationResult result = PlayModeToolPreflightService.RequireActiveAndNotPaused(RecordInputUseCase.PausedActionDescription); + PlayModeToolPreflightResult result = PlayModeToolPreflightService.RequireActiveAndNotPaused(RecordInputUseCase.PausedActionDescription); Assert.That(result.IsValid, Is.False); Assert.That(result.ErrorMessage, Is.EqualTo(ExpectedNotActiveMessage)); diff --git a/Assets/Tests/Editor/SkillInstallLayoutTests.cs b/Assets/Tests/Editor/SkillInstallLayoutTests.cs index 2ed19de655..38442ad8c2 100644 --- a/Assets/Tests/Editor/SkillInstallLayoutTests.cs +++ b/Assets/Tests/Editor/SkillInstallLayoutTests.cs @@ -102,12 +102,12 @@ public void AreSkillsInstalled_WhenLayoutSpecified_MatchesOnlySelectedLayout() public void AreSkillsInstalled_WhenLegacyManagedDirectoryIsEmpty_StillDetectsFlatLayout() { string temporaryRoot = CreateTemporaryProjectRoot(); - string targetRoot = Path.Combine(temporaryRoot, ".cursor"); + string targetRoot = Path.Combine(temporaryRoot, ".codex"); Directory.CreateDirectory(Path.Combine(targetRoot, SkillInstallLayout.SkillsDirName, "uloop-compile")); SkillInstallationDetector detector = new(); - Assert.IsTrue(detector.AreSkillsInstalledForLayout(temporaryRoot, ".cursor", false)); - Assert.IsFalse(detector.AreSkillsInstalledForLayout(temporaryRoot, ".cursor", true)); + Assert.IsTrue(detector.AreSkillsInstalledForLayout(temporaryRoot, ".codex", false)); + Assert.IsFalse(detector.AreSkillsInstalledForLayout(temporaryRoot, ".codex", true)); } // Tests that Unity-side discovery includes CLI-only skills from the packaged core CLI. diff --git a/Assets/Tests/Editor/SkillParameterTableCoverageTests.cs b/Assets/Tests/Editor/SkillParameterTableCoverageTests.cs new file mode 100644 index 0000000000..49ca4e5f8e --- /dev/null +++ b/Assets/Tests/Editor/SkillParameterTableCoverageTests.cs @@ -0,0 +1,483 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.CompositionRoot; +using io.github.hatayama.UnityCliLoop.Domain; +using io.github.hatayama.UnityCliLoop.ToolContracts; +using ToolParameterInfo = io.github.hatayama.UnityCliLoop.ToolContracts.ParameterInfo; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Test fixture that verifies the skill parameter tables document every parameter the tools + /// actually accept. + /// Why this lives in C#: `--help`, `uloop list` and the embedded catalog all render from those + /// tables now, so a parameter with no table row is a parameter no agent can discover - and the set + /// of accepted parameters is decided by the C# schema types, which no Go test can see. The reverse + /// direction (a row for an option no tool accepts) is caught by the catalog generator instead. + /// + public sealed class SkillParameterTableCoverageTests + { + private const string DefaultToolsPath = "cli/common/tools/default-tools.json"; + private const string PackageEditorPath = "Packages/src/Editor"; + private const string SkillFileName = "SKILL.md"; + private const string SkillDirectoryName = "Skill"; + private const string SkillNamePrefix = "uloop-"; + private const string ParametersSectionHeading = "## Parameters"; + private const string SectionHeadingPrefix = "## "; + private const string SubsectionHeadingPrefix = "### "; + private const string FrontmatterFence = "---"; + + // Why: the package keeps first-party tool skills and CLI-only command skills in two containers, + // and the tilde suffix hides the second one from Unity's asset database (so those files need no + // .meta). Both are read here because Unity-side tools are documented in both: the pause-point + // commands live under CliOnlyTools~ even though Unity accepts their parameters. + private static readonly string[] SkillContainerDirectories = { "FirstPartyTools", "CliOnlyTools~" }; + + private static readonly string[] StandardParameterTableCells = { "Parameter", "Type", "Default", "Description" }; + + /// + /// Verifies every parameter a live tool accepts has a matching row in its skill's parameter + /// table, so adding a schema property without documenting it fails here instead of silently + /// producing an option that help cannot describe. + /// + [Test] + public void EverySchemaParameter_HasASkillTableRow() + { + Dictionary skills = ReadSkillDocumentation(); + HashSet hiddenProperties = ReadHiddenPropertyKeys(); + HashSet firstPartyToolNames = new(ReadCatalogToolNames(), StringComparer.Ordinal); + List problems = new(); + + foreach (ToolInfo tool in ReadLiveTools()) + { + // Why the catalog defines the scope: this development project also registers the custom + // command samples and test fixtures under Assets/, which are project-local tools with no + // skill by design. The catalog is exactly the set of commands the CLI ships. + if (!firstPartyToolNames.Contains(tool.Name)) + { + continue; + } + + if (!skills.TryGetValue(tool.Name, out SkillDocumentation documentation)) + { + problems.Add(tool.Name + ": no skill documents this tool"); + continue; + } + + foreach (KeyValuePair property in + tool.ParameterSchema.Properties.OrderBy(property => property.Key, StringComparer.Ordinal)) + { + if (hiddenProperties.Contains(HiddenPropertyKey(tool.Name, property.Key))) + { + continue; + } + + string optionName = OptionNameForProperty(tool.Name, property.Key, property.Value); + if (documentation.DocumentsOption(optionName)) + { + continue; + } + + problems.Add(tool.Name + " --" + optionName + ": no row in " + documentation.SkillRelativePath); + } + } + + Assert.That(problems, Is.Empty, string.Join("\n", problems)); + } + + /// + /// Verifies every command in the embedded catalog has a non-empty tool description in a skill, + /// which is what `uloop list` and `--help` print as the command's one-line summary. + /// + [Test] + public void EveryCatalogTool_HasASkillDescription() + { + Dictionary skills = ReadSkillDocumentation(); + List problems = new(); + + foreach (string toolName in ReadCatalogToolNames()) + { + if (!skills.TryGetValue(toolName, out SkillDocumentation documentation)) + { + problems.Add(toolName + ": no skill documents this tool"); + continue; + } + + if (string.IsNullOrEmpty(documentation.Description)) + { + problems.Add(toolName + ": skill " + documentation.SkillRelativePath + " has an empty description"); + } + } + + Assert.That(problems, Is.Empty, string.Join("\n", problems)); + } + + private static ToolInfo[] ReadLiveTools() + { + UnityCliLoopToolRegistry registry = new UnityCliLoopToolRegistry( + new AlwaysEnabledToolSettingsPort(), + internalToolNameProvider: null, + toolDiscovery: UnityCliLoopToolDiscovery.DiscoverTools); + + return registry.GetRegisteredTools() + .OrderBy(tool => tool.Name, StringComparer.Ordinal) + .ToArray(); + } + + private static string[] ReadCatalogToolNames() + { + JArray tools = ReadCatalog()["tools"] as JArray ?? new JArray(); + return tools + .OfType() + .Select(tool => tool["name"]?.ToString() ?? "") + .Where(name => name.Length > 0) + .OrderBy(name => name, StringComparer.Ordinal) + .ToArray(); + } + + // Why the hidden set is read from the catalog rather than from a list in this file: whether an + // option reaches the command line is a CLI-side decision recorded by the catalog's "hidden" + // flag, and the catalog generator skips exactly those properties. Reading the same flag keeps + // one source for it; a second list here could disagree with the generator. + private static HashSet ReadHiddenPropertyKeys() + { + HashSet hiddenKeys = new(StringComparer.Ordinal); + JArray tools = ReadCatalog()["tools"] as JArray ?? new JArray(); + foreach (JObject tool in tools.OfType()) + { + string toolName = tool["name"]?.ToString() ?? ""; + JObject properties = tool["inputSchema"]?["properties"] as JObject ?? new JObject(); + foreach (JProperty property in properties.Properties()) + { + if (property.Value["hidden"]?.Value() == true) + { + hiddenKeys.Add(HiddenPropertyKey(toolName, property.Name)); + } + } + } + return hiddenKeys; + } + + private static JObject ReadCatalog() + { + string projectRoot = UnityCliLoopPathResolver.GetProjectRoot(); + return JObject.Parse(File.ReadAllText(Path.Combine(projectRoot, DefaultToolsPath))); + } + + private static string HiddenPropertyKey(string toolName, string propertyName) + { + return toolName + "/" + propertyName; + } + + // Why this duplicates cli/common/tooldocs.OptionNameForProperty instead of sharing it: the rule + // has to run on both sides of the language boundary, and the two directions guard each other - + // a divergence here makes this test demand a row that does not exist, and a divergence there + // makes the catalog generator reject a row it cannot match. + private static string OptionNameForProperty(string toolName, string propertyName, ToolParameterInfo property) + { + string kebabName = PascalToKebab(propertyName); + if (!IsNegatedBooleanProperty(property)) + { + return kebabName; + } + + // These two flags read as an action rather than as the negation of a property name. + if (toolName == "run-tests" && propertyName == "SaveBeforeRun") + { + return "fail-on-unsaved-changes"; + } + if (toolName == "compile" && propertyName == "ReloadExternalSceneChanges") + { + return "stop-on-external-scene-changes"; + } + return "no-" + kebabName; + } + + private static bool IsNegatedBooleanProperty(ToolParameterInfo property) + { + return string.Equals(property.Type, "boolean", StringComparison.OrdinalIgnoreCase) && + property.DefaultValue is bool defaultValue && + defaultValue; + } + + private static string PascalToKebab(string value) + { + System.Text.StringBuilder builder = new(); + for (int index = 0; index < value.Length; index++) + { + if (index > 0 && value[index] >= 'A' && value[index] <= 'Z') + { + builder.Append('-'); + } + builder.Append(value[index]); + } + return builder.ToString().ToLowerInvariant(); + } + + private static Dictionary ReadSkillDocumentation() + { + Dictionary documentation = new(StringComparer.Ordinal); + foreach (string skillPath in EnumerateSkillFiles()) + { + string relativePath = Path.GetFileName(Path.GetDirectoryName(Path.GetDirectoryName(skillPath))) + + "/" + SkillDirectoryName + "/" + SkillFileName; + foreach (KeyValuePair entry in ParseSkill(File.ReadAllText(skillPath), relativePath)) + { + documentation[entry.Key] = entry.Value; + } + } + return documentation; + } + + private static string[] EnumerateSkillFiles() + { + string editorRoot = Path.Combine(UnityCliLoopPathResolver.GetProjectRoot(), PackageEditorPath); + return SkillContainerDirectories + .Select(container => Path.Combine(editorRoot, container)) + .Where(Directory.Exists) + .SelectMany(container => Directory.GetDirectories(container)) + .Select(toolDirectory => Path.Combine(toolDirectory, SkillDirectoryName, SkillFileName)) + .Where(File.Exists) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray(); + } + + // Why the parsing here is deliberately smaller than the Go parser in cli/common/skilldocs: this + // guard only asks whether a row for an option exists, so it never interprets a description's + // text. That keeps the duplicated understanding of the file format to the two things a missing + // row depends on - where the table is and what its first column says. + private static Dictionary ParseSkill(string content, string relativePath) + { + string[] lines = content + .Replace("\r\n", "\n") + .Replace("\r", "\n") + .Split('\n'); + string[] parametersSection = ReadParametersSection(lines); + if (parametersSection.Any(line => line.TrimStart().StartsWith(SubsectionHeadingPrefix, StringComparison.Ordinal))) + { + return ParseMultiToolSkill(parametersSection, relativePath); + } + return ParseSingleToolSkill(lines, relativePath); + } + + private static Dictionary ParseSingleToolSkill(string[] lines, string relativePath) + { + Dictionary frontmatter = ReadFrontmatter(lines); + string toolName = SingleSkillToolName(frontmatter); + Dictionary documentation = new(StringComparer.Ordinal); + if (toolName.Length == 0) + { + return documentation; + } + + documentation[toolName] = new SkillDocumentation( + frontmatter.TryGetValue("description", out string description) ? description : "", + ReadTableOptionNames(lines), + relativePath); + return documentation; + } + + private static Dictionary ParseMultiToolSkill(string[] sectionLines, string relativePath) + { + Dictionary documentation = new(StringComparer.Ordinal); + for (int index = 0; index < sectionLines.Length; index++) + { + string line = sectionLines[index].Trim(); + if (!line.StartsWith(SubsectionHeadingPrefix, StringComparison.Ordinal)) + { + continue; + } + + string toolName = line.Substring(SubsectionHeadingPrefix.Length).Trim(); + if (toolName.Length == 0) + { + continue; + } + + string[] blockLines = ReadSubsection(sectionLines, index); + documentation[toolName] = new SkillDocumentation( + FirstProseLine(blockLines), + ReadTableOptionNames(blockLines), + relativePath); + } + return documentation; + } + + private static string[] ReadParametersSection(string[] lines) + { + for (int index = 0; index < lines.Length; index++) + { + if (lines[index].Trim() != ParametersSectionHeading) + { + continue; + } + + for (int end = index + 1; end < lines.Length; end++) + { + if (lines[end].TrimStart().StartsWith(SectionHeadingPrefix, StringComparison.Ordinal)) + { + return lines.Skip(index + 1).Take(end - index - 1).ToArray(); + } + } + return lines.Skip(index + 1).ToArray(); + } + return Array.Empty(); + } + + private static string[] ReadSubsection(string[] sectionLines, int headingIndex) + { + for (int end = headingIndex + 1; end < sectionLines.Length; end++) + { + if (sectionLines[end].TrimStart().StartsWith(SubsectionHeadingPrefix, StringComparison.Ordinal)) + { + return sectionLines.Skip(headingIndex + 1).Take(end - headingIndex - 1).ToArray(); + } + } + return sectionLines.Skip(headingIndex + 1).ToArray(); + } + + private static string FirstProseLine(string[] lines) + { + foreach (string line in lines) + { + string trimmed = line.Trim(); + if (trimmed.Length == 0 || trimmed.StartsWith("|", StringComparison.Ordinal)) + { + continue; + } + return trimmed; + } + return ""; + } + + private static Dictionary ReadFrontmatter(string[] lines) + { + Dictionary frontmatter = new(StringComparer.Ordinal); + if (lines.Length == 0 || lines[0].Trim() != FrontmatterFence) + { + return frontmatter; + } + + for (int index = 1; index < lines.Length && lines[index].Trim() != FrontmatterFence; index++) + { + int separatorIndex = lines[index].IndexOf(':'); + if (separatorIndex <= 0) + { + continue; + } + + string key = lines[index].Substring(0, separatorIndex).Trim(); + string value = lines[index].Substring(separatorIndex + 1).Trim().Trim('"'); + frontmatter[key] = value; + } + return frontmatter; + } + + // Why the skill name is a fallback: toolName is authoritative when present, and every skill in + // this package is named "uloop-" for the tools that declare no toolName. + private static string SingleSkillToolName(Dictionary frontmatter) + { + if (frontmatter.TryGetValue("toolName", out string toolName) && toolName.Length > 0) + { + return toolName; + } + if (!frontmatter.TryGetValue("name", out string name) || !name.StartsWith(SkillNamePrefix, StringComparison.Ordinal)) + { + return ""; + } + return name.Substring(SkillNamePrefix.Length); + } + + // Only the first standard-header table counts, matching the Go parser: help renders that one + // table, so a row placed in a second table would be documentation this guard accepts and help + // never shows. + private static HashSet ReadTableOptionNames(string[] lines) + { + HashSet optionNames = new(StringComparer.Ordinal); + for (int index = 0; index < lines.Length; index++) + { + if (!IsStandardParameterTableHeader(lines[index])) + { + continue; + } + + for (int row = index + 1; row < lines.Length && lines[row].TrimStart().StartsWith("|", StringComparison.Ordinal); row++) + { + // The separator row's cells are dashes only, which leaves no option name behind. + string optionName = OptionNameFromCell(SplitTableRow(lines[row]).FirstOrDefault() ?? ""); + if (optionName.Length > 0) + { + optionNames.Add(optionName); + } + } + return optionNames; + } + return optionNames; + } + + private static bool IsStandardParameterTableHeader(string line) + { + string[] cells = SplitTableRow(line); + return cells.Length == StandardParameterTableCells.Length && + cells.SequenceEqual(StandardParameterTableCells, StringComparer.Ordinal); + } + + // The first column never contains an escaped pipe, so splitting the row structurally is enough + // to read it; only descriptions carry "\|" and this guard never looks at them. + // Why not handle the escape: nothing here reads a description. Extending this to read one needs + // the same escape handling cli/common/skilldocs.splitTableRow does, or cells will be truncated. + private static string[] SplitTableRow(string line) + { + string trimmed = line.Trim(); + if (!trimmed.StartsWith("|", StringComparison.Ordinal)) + { + return Array.Empty(); + } + + return trimmed + .Trim('|') + .Split('|') + .Select(cell => cell.Trim()) + .ToArray(); + } + + private static string OptionNameFromCell(string cell) + { + string name = cell.Replace("`", "").Trim(); + string[] fields = name.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries); + if (fields.Length == 0) + { + return ""; + } + return fields[0].TrimStart('-'); + } + + /// + /// One tool's documentation as this guard needs it: the description shown in help and the set + /// of options the parameter table has a row for. + /// + private sealed class SkillDocumentation + { + public readonly string Description; + public readonly string SkillRelativePath; + private readonly HashSet optionNames; + + public SkillDocumentation(string description, HashSet optionNames, string skillRelativePath) + { + Description = description; + SkillRelativePath = skillRelativePath; + this.optionNames = optionNames; + } + + public bool DocumentsOption(string optionName) + { + return optionNames.Contains(optionName); + } + } + } +} diff --git a/Assets/Tests/Editor/SkillParameterTableCoverageTests.cs.meta b/Assets/Tests/Editor/SkillParameterTableCoverageTests.cs.meta new file mode 100644 index 0000000000..64c7e5aa02 --- /dev/null +++ b/Assets/Tests/Editor/SkillParameterTableCoverageTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b1596eda8b7ab43f399723f4aefb83ba +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/ToolSkillSynchronizerTests.cs b/Assets/Tests/Editor/ToolSkillSynchronizerTests.cs index 796de18007..0c14d29c0f 100644 --- a/Assets/Tests/Editor/ToolSkillSynchronizerTests.cs +++ b/Assets/Tests/Editor/ToolSkillSynchronizerTests.cs @@ -894,7 +894,7 @@ public void DetectTargets_WhenGroupedLayoutRequested_IgnoresFlatInstalledSkills( public void DetectTargets_WhenGroupedLayoutRequested_DetectsEmptyFlatManagedDirectories() { string temporaryRoot = CreateTemporaryProjectRoot(); - string targetRoot = Path.Combine(temporaryRoot, ".cursor"); + string targetRoot = Path.Combine(temporaryRoot, ".claude"); Directory.CreateDirectory(Path.Combine(targetRoot, SkillInstallLayout.SkillsDirName, "uloop-compile")); ToolSkillSynchronizer.SkillTargetInfo[] detectedTargets = SkillTargetDetector.DetectTargetsForLayoutStateAtProjectRoot( diff --git a/Assets/Tests/PlayMode/RecordInputKeyFilterRejectionTests.cs b/Assets/Tests/PlayMode/RecordInputKeyFilterRejectionTests.cs new file mode 100644 index 0000000000..ff989663d6 --- /dev/null +++ b/Assets/Tests/PlayMode/RecordInputKeyFilterRejectionTests.cs @@ -0,0 +1,57 @@ +#if ULOOP_HAS_INPUT_SYSTEM +#nullable enable +using System.Collections; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using UnityEngine.TestTools; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.PlayMode +{ + /// + /// Test fixture that verifies record-input refuses to start when the key filter names no key. + /// Runs in PlayMode because the rejection sits behind the PlayMode preflight. + /// + public sealed class RecordInputKeyFilterRejectionTests + { + [TearDown] + public void TearDown() + { + // A regression here starts a real recording, which would leak into the next test. + if (InputRecorder.IsRecording) + { + InputRecorder.StopRecording(); + } + } + + /// + /// Tests that a key filter naming no key fails the command instead of recording every key. + /// + [UnityTest] + public IEnumerator RecordInput_WhenTheKeyFilterNamesNoKey_FailsWithoutRecording() + { + RecordInputSchema request = new() + { + Action = RecordInputAction.Start, + Keys = "3", + DelaySeconds = 0, + ShowOverlay = false + }; + + Task execution = new RecordInputUseCase().RecordInputAsync(request, CancellationToken.None); + while (!execution.IsCompleted) + { + yield return null; + } + + RecordInputResponse response = execution.Result; + Assert.IsFalse(response.Success, response.Message); + StringAssert.Contains("Invalid key name(s) in the keys filter: 3", response.Message); + Assert.IsFalse(InputRecorder.IsRecording); + } + } +} +#endif diff --git a/Assets/Tests/PlayMode/RecordInputKeyFilterRejectionTests.cs.meta b/Assets/Tests/PlayMode/RecordInputKeyFilterRejectionTests.cs.meta new file mode 100644 index 0000000000..476c7bb13d --- /dev/null +++ b/Assets/Tests/PlayMode/RecordInputKeyFilterRejectionTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2181da05a0170455b82212942745e63d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/PlayMode/SimulateKeyboardTests.cs b/Assets/Tests/PlayMode/SimulateKeyboardTests.cs index 38fafa755f..a63ba4fd9c 100644 --- a/Assets/Tests/PlayMode/SimulateKeyboardTests.cs +++ b/Assets/Tests/PlayMode/SimulateKeyboardTests.cs @@ -634,6 +634,194 @@ public IEnumerator Press_WithInvalidKey_Should_ReturnFailure() StringAssert.Contains("Invalid key name", lastResponse.Message); } + /// + /// Verifies that a bare digit key is rejected instead of being parsed as an enum ordinal, + /// and that the failure message offers both the Digit and Numpad candidates. + /// + [UnityTest] + public IEnumerator Press_WithBareDigitKey_Should_ReturnFailureSuggestingDigitAndNumpad() + { + yield return null; + + yield return RunTool(new JObject + { + ["action"] = KeyboardAction.Press.ToString(), + ["key"] = "3" + }); + + Assert.IsFalse(lastResponse.Success, "A bare digit must not be accepted as a Key enum ordinal"); + StringAssert.Contains("Digit3", lastResponse.Message); + StringAssert.Contains("Numpad3", lastResponse.Message); + StringAssert.Contains("Digits are not key names", lastResponse.Message); + StringAssert.Contains("re-check any earlier results", lastResponse.Message); + } + + /// + /// Verifies that a signed numeric key is rejected, closing the Enum.TryParse signed-ordinal path. + /// + [UnityTest] + public IEnumerator Press_WithSignedNumericKey_Should_ReturnFailure() + { + yield return null; + + yield return RunTool(new JObject + { + ["action"] = KeyboardAction.Press.ToString(), + ["key"] = "+3" + }); + + Assert.IsFalse(lastResponse.Success, "A signed numeric key must not be accepted as a Key enum ordinal"); + StringAssert.Contains("Digits are not key names", lastResponse.Message); + StringAssert.Contains("re-check any earlier results", lastResponse.Message); + } + + /// + /// Verifies that a comma-separated key list is rejected, closing the Enum.TryParse flag-OR path. + /// + [UnityTest] + public IEnumerator Press_WithCommaSeparatedKeyNames_Should_ReturnFailure() + { + yield return null; + + yield return RunTool(new JObject + { + ["action"] = KeyboardAction.Press.ToString(), + ["key"] = "Space,Enter" + }); + + Assert.IsFalse(lastResponse.Success, "A comma-separated key list must not be OR-ed into a single key"); + StringAssert.Contains("Invalid key name", lastResponse.Message); + // The digit guidance is scoped to numeric input, so it must not appear here. + StringAssert.DoesNotContain("Digits are not key names", lastResponse.Message); + } + + /// + /// Verifies that a whitespace-padded digit is rejected, closing the Enum.TryParse path that + /// trimmed the input before reading it as an enum ordinal. + /// + [UnityTest] + public IEnumerator Press_WithPaddedDigitKey_Should_ReturnFailure() + { + yield return null; + + yield return RunTool(new JObject + { + ["action"] = KeyboardAction.Press.ToString(), + ["key"] = " 3 " + }); + + Assert.IsFalse(lastResponse.Success, "A whitespace-padded digit must not be accepted as a Key enum ordinal"); + StringAssert.Contains("Digits are not key names", lastResponse.Message); + } + + /// + /// Verifies that an out-of-range numeric key fails as a validation error instead of throwing + /// ArgumentOutOfRangeException from the keyboard indexer. + /// + [UnityTest] + public IEnumerator Press_WithUndefinedNumericKey_Should_ReturnFailure() + { + yield return null; + + yield return RunTool(new JObject + { + ["action"] = KeyboardAction.Press.ToString(), + ["key"] = "300" + }); + + Assert.IsFalse(lastResponse.Success, "An undefined numeric key must fail validation rather than throw"); + StringAssert.Contains("Digits are not key names", lastResponse.Message); + StringAssert.Contains("re-check any earlier results", lastResponse.Message); + } + + /// + /// Verifies that the existing Return-to-Enter alias still resolves after key names are whitelisted. + /// + [UnityTest] + public IEnumerator Press_WithReturnAlias_Should_Succeed() + { + yield return null; + + yield return RunTool(new JObject + { + ["action"] = KeyboardAction.Press.ToString(), + ["key"] = "Return" + }); + + Assert.IsTrue(lastResponse.Success, lastResponse.Message); + } + + /// + /// Verifies that key names resolve case-insensitively, which the whitelist must preserve + /// because Enum.TryParse was previously called with ignoreCase. + /// + [UnityTest] + public IEnumerator Press_WithLowercaseKeyName_Should_Succeed() + { + yield return null; + + yield return RunTool(new JObject + { + ["action"] = KeyboardAction.Press.ToString(), + ["key"] = "space" + }); + + Assert.IsTrue(lastResponse.Success, lastResponse.Message); + } + + /// + /// Verifies that Digit3, the name the rejection message advertises for bare digits, is + /// actually accepted. + /// + [UnityTest] + public IEnumerator Press_WithDigitKeyName_Should_Succeed() + { + yield return null; + + yield return RunTool(new JObject + { + ["action"] = KeyboardAction.Press.ToString(), + ["key"] = "Digit3" + }); + + Assert.IsTrue(lastResponse.Success, lastResponse.Message); + } + + /// + /// Verifies that a whitespace-padded valid key name keeps resolving, since padded correct + /// input was already accepted before key names were whitelisted. + /// + [UnityTest] + public IEnumerator Press_WithPaddedKeyName_Should_Succeed() + { + yield return null; + + yield return RunTool(new JObject + { + ["action"] = KeyboardAction.Press.ToString(), + ["key"] = " Space " + }); + + Assert.IsTrue(lastResponse.Success, lastResponse.Message); + } + + /// + /// Verifies that the Return-to-Enter alias still applies when the name is whitespace-padded. + /// + [UnityTest] + public IEnumerator Press_WithPaddedReturnAlias_Should_Succeed() + { + yield return null; + + yield return RunTool(new JObject + { + ["action"] = KeyboardAction.Press.ToString(), + ["key"] = " Return " + }); + + Assert.IsTrue(lastResponse.Success, lastResponse.Message); + } + [UnityTest] public IEnumerator Press_WithEmptyKey_Should_ReturnFailure() { diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md index 48f1eef9c7..d68d90dfd2 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -16,7 +16,9 @@ Use this small loop for one representative frame you care about. No source edit uloop enable-pause-point --file Assets/Scripts/Enemy.cs --line 42 --timeout-seconds 30 --await --trigger "simulate-keyboard --action Press --key Space" ``` -`--trigger` runs a single uloop subcommand in-process only after the marker's arming is confirmed, so there is no arm-vs-input race and nothing needs to run in the background. The hit response additionally carries `TriggerResult` with the triggered command's own response (or, when the trigger was skipped, `Completed: false` and the reason in `Error`). The trigger string cannot name another pause-point wait (`await-pause-point`/`enable-pause-point`) and cannot pass `--project-path` — the enclosing command's project is used. `await-pause-point --id --trigger ...` accepts the same flag for a marker enabled earlier. Both commands also accept `--resume-play` — see Fast-Progressing Games. +Digit keys are `Digit0`-`Digit9` or `Numpad0`-`Numpad9` — bare `0`-`9` is rejected. + +Before writing a `--trigger` command that differs from the example, load the skill of the tool you are about to trigger. `--trigger` runs a single uloop subcommand in-process only after the marker's arming is confirmed, so the input cannot land before arming and nothing needs to run in the background. One race does remain: the marker itself can hit before the trigger executes (for example on a line that runs every frame), in which case the trigger is rejected because PlayMode is already paused and runs nothing — the hit response then carries `TriggerFailed: true` at the top level and a `Warning` explaining that no input reached the game (the triggered command's own response stays in `TriggerResult`), so do not treat such a hit as input-driven. If the trigger command itself is rejected before it runs — its argument parsing fails (`INVALID_ARGUMENT`) or the command name is unknown (`UNKNOWN_COMMAND`) — the wait is abandoned immediately with a `PAUSE_POINT_TRIGGER_FAILED` error instead of waiting out `--timeout-seconds`: the marker stays armed, a PlayMode resumed by `--resume-play` is paused again, and `Error.NextActions` carries the recovery commands — fix the trigger value and re-run the same command. The hit response additionally carries `TriggerResult` with the triggered command's own response (or, when the trigger was skipped, `Completed: false` and the reason in `Error`). The trigger string cannot name another pause-point wait (`await-pause-point`/`enable-pause-point`) and cannot pass `--project-path` — the enclosing command's project is used. `await-pause-point --id --trigger ...` accepts the same flag for a marker enabled earlier. Both commands also accept `--resume-play` — see Fast-Progressing Games. When the game reaches the line on its own, omit `--trigger`. Fall back to split steps only when the triggering action is not a single uloop command (several inputs in sequence, an external event): run `enable-pause-point` without `--await` in the foreground (its response returning is the arm confirmation), then start `uloop await-pause-point --id ` in the background, then send the inputs. Do not approximate arm-waiting with a fixed sleep after a backgrounded enable. @@ -30,6 +32,63 @@ The response returns the derived marker `Id` (`Assets/Scripts/Enemy.cs:42`), the A hit pauses Unity at the next frame boundary — the patched method and the rest of that frame still run to completion. Only `CapturedVariables` is evidence of the values at the patched line; state read after the pause (for example via `execute-dynamic-code`) may already have advanced past it. +## Parameters + +One skill covers several commands, so each command's schema parameters have their own table below. +CLI-only flags (`--await`, `--trigger`, `--resume-play`, `--expect`, `--captured-variables`, +`--captured-variable-names`, `--matching-logs-max-count`) are described in the sections above; only +parameters Unity itself accepts appear here. + +### enable-pause-point + +Enable a pause point so Unity pauses when that code path is reached, either by a named UloopPausePoint.Pause marker (Id) or by resolving a source file and line (File+Line) + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--id` | string | - | Named pause point id passed to UloopPausePoint.Pause. Mutually exclusive with File/Line | +| `--file` | string | - | Project-relative source file path to patch a pause point into. Requires Line; mutually exclusive with Id | +| `--line` | integer | - | 1-based source line to resolve within File. Requires File; mutually exclusive with Id | +| `--timeout-seconds` | integer | `30` | Seconds before the enable request expires and stops pausing late hits | +| `--mode` | enum | `single-shot` | Capture mode: single-shot pauses once, continuous pauses on every hit, trace records hits without pausing | +| `--max-history` | integer | `20` | Maximum number of captured hit frames to retain (1-100) | +| `--max-preview-elements` | integer | `10` | Maximum number of elements to include in a captured collection's preview (1-1000). The value set at enable time also caps the previews in every later pause-point-status response for that marker; status has no flag to change it. | + +### clear-pause-point + +Clear one or all named UloopPausePoint.Pause markers + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--id` | string | - | Named pause point id to clear | +| `--all` | flag | - | Clear every active pause point marker | + +### enable-watch + +Register a C# expression to evaluate on each paused Play Mode step + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--id` | string | - | Unique watch expression identifier | +| `--expression` | string | - | C# expression returning an object; UloopPausePoint.TryGetCapturedValue can read the latest raw capture | +| `--max-history` | integer | `20` | Maximum number of watch evaluations to retain (1-100) | + +### get-watch-values + +Show registered watch expression values and bounded evaluation history + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--id` | string | - | Optional watch expression identifier; omit to return all watches | + +### clear-watch + +Clear one or all registered C# watch expressions + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--id` | string | - | Watch expression identifier to clear | +| `--all` | flag | - | Clear every registered watch expression | + ## Capture Modes and History Choose the capture mode when enabling a pause point: @@ -51,9 +110,9 @@ Every hit response embeds `CapturedVariables`: the method's in-scope locals, its - The snapshot is taken **before** the resolved line executes, exactly like an IDE breakpoint on that line. To inspect a value after an assignment, place the pause point on the following line. - `Scope` is `Local`, `Parameter`, `InstanceField`, or `This`. The synthetic `this` entry identifies which instance or GameObject was hit via `UnityObjectPath` and `UnityObjectInstanceId`; `UnityEngine.Object` values carry the same handle fields for follow-up digs with `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code`. - `--captured-variables names` on `await-pause-point`/`pause-point-status` drops every `Value` and keeps `Name`/`Scope`/`TypeName` — use it first on field-heavy classes, then fetch full values with a plain `pause-point-status` call. -- When the response would be dominated by variables you do not need, pass `--captured-variable-names velocity,this` (comma-separated, exact match on `Name`) to keep only those entries; it composes with `--captured-variables full|names`. -- Pass `--expect 'name=value'` (repeatable; on `await-pause-point` and `enable-pause-point --await`, not `pause-point-status`) to have the CLI compare captured variables against expected values; the response includes an `Expectations` array and `AllExpectationsPassed`, so you do not need to eyeball the JSON. Matching is string equality against the serialized value. -- Collection values (arrays, `List`, dictionaries, plain objects) render as a JSON preview capped at 10 elements by default. When the elements you need sit past that cap (a 10x20 grid, a long list), re-enable with `--max-preview-elements ` (1–1000). +- When the response would be dominated by variables you do not need, pass `--captured-variable-names velocity,this` (comma-separated, exact match on `Name`) to keep only those entries; it composes with `--captured-variables full|names`. `CapturedVariablesTruncated` in the response reports truncation at Unity-side capture time and is unrelated to this name filter — it can be `true` even when every requested name was found. Requested names that matched nothing are listed in `CapturedVariableNamesNotFound`, so a partial match is visible without comparing the response against the request by hand. +- Pass `--expect 'name=value'` (repeatable; on `await-pause-point`, `enable-pause-point --await`, and `pause-point-status`) to have the CLI compare captured variables against expected values; the response includes an `Expectations` array and `AllExpectationsPassed`, so you do not need to eyeball the JSON. Matching is string equality against the serialized value. On `pause-point-status` a marker that has not been hit yet reports each expectation as not found, and the verdict never changes the exit code — a polling loop reads `AllExpectationsPassed`, not the exit status. +- Collection values (arrays, `List`, dictionaries, plain objects) render as a JSON preview capped at 10 elements by default. When the elements you need sit past that cap (a 10x20 grid, a long list), re-enable with `--max-preview-elements ` (1–1000). The value set at enable time also caps the previews in every later `pause-point-status` response for that marker — status has no flag to change it. - While Unity is still paused, `UloopPausePoint.TryGetCapturedValue("name")` (and `"this"`) returns live captured references for `execute-dynamic-code`; the return is a `(bool Found, object Value)` tuple, and the holder clears on resume. (file:line marker hits only — id-only markers store no capture) These are **live objects in their frame-completed state, not snapshots** — use them only to dig further into objects that are still alive, never to reconstruct what a value was at the paused line. For snapshot timing, preview/truncation caps, Unity-object `Value` semantics, capture-time vs live evidence, `Warning`/`MatchingLogs`, marker freshness, and the raw capture API, read [references/captured-variables.md](references/captured-variables.md). @@ -93,7 +152,7 @@ For "N frames after the input" (for example, three frames after a key press), ad A pause point hits only when control flow reaches the patched line (or the `Pause(id)` call). `simulate-keyboard` returning `PressEdgeObserved=true` means the input edge was observed, not that your target game logic has reached the pause line yet. -If a `simulate-*` command instead returns a failure whose message says PlayMode is paused, suspect a pause point hit rather than an unrelated failure: an active pause point can make PlayMode paused mid-simulation, and the `simulate-*` call surfaces that as a preflight failure. Check `uloop pause-point-status --id ` first to confirm the hit before treating it as a bug in the simulated action itself. +If a `simulate-*` command instead returns a failure whose message says PlayMode is paused, suspect a pause point hit rather than an unrelated failure: an active pause point can make PlayMode paused mid-simulation, and the `simulate-*` call surfaces that as a preflight failure. The failure response names the responsible marker in `RejectedByActivePausePointId`. Check `uloop pause-point-status --id ` first to confirm the hit before treating it as a bug in the simulated action itself. ## When To Use @@ -116,9 +175,11 @@ When the game advances on its own (timers, gravity, spawners), any state you arr ```bash uloop enable-pause-point --file Assets/Scripts/Enemy.cs --line 42 --timeout-seconds 60 \ - --await --resume-play --trigger "simulate-keyboard --action Press --key Space" + --await --resume-play --trigger "simulate-keyboard --action Press --key Digit3" ``` +Digit keys are `Digit0`-`Digit9` or `Numpad0`-`Numpad9` — bare `0`-`9` is rejected. + `--resume-play` (requires `--await`; `await-pause-point` accepts it too) resumes a paused PlayMode after the marker's arming is confirmed and before `--trigger` is dispatched. Size `--timeout-seconds` generously when arming while paused: a manual Pause does not freeze the marker countdown (see Timeout Checks). For `ResumePlayResult` semantics, why `Time.timeScale = 0` is not a substitute for pausing, the residual post-resume race, and why direct state writes may not stick while paused, read [references/fast-progressing-games.md](references/fast-progressing-games.md). diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/references/captured-variables.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/references/captured-variables.md index e19809a8cc..6588496374 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/references/captured-variables.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/references/captured-variables.md @@ -78,6 +78,7 @@ return value; ``` The references are live objects in their frame-completed state: anything the hit's method changed — or destroyed — after the patched line is already applied, so a captured object can read as destroyed/null here even though `CapturedVariables` shows its pre-line field values intact. +Concrete example: with a marker on the line right before `Destroy(obj)`, `TryGetCapturedValue("obj")` returns the frame-completed object — already destroyed — while the pre-destroy field values are still readable in `CapturedVariables`. The holder clears when Unity resumes (not when you `Step` while still paused), when the matching pause point is cleared, when a new hit replaces the snapshot, or when PlayMode exits. After resume, `TryGetCapturedValue` returns `Found=false`. Re-enabling the same pause point while still paused (for example to refresh its timeout during a step session) keeps the held references, because a re-enable does not resume Unity. diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/references/fast-progressing-games.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/references/fast-progressing-games.md index a7b0c300a6..60017b8600 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/references/fast-progressing-games.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/references/fast-progressing-games.md @@ -15,12 +15,14 @@ uloop execute-dynamic-code --code '...' # 3) One call: confirm the marker armed, resume PlayMode, fire the input, await the hit uloop enable-pause-point --file Assets/Scripts/Enemy.cs --line 42 --timeout-seconds 60 \ - --await --resume-play --trigger "simulate-keyboard --action Press --key Space" + --await --resume-play --trigger "simulate-keyboard --action Press --key Digit3" ``` +Digit keys are `Digit0`-`Digit9` or `Numpad0`-`Numpad9` — bare `0`-`9` is rejected. + ## --resume-play Semantics -`--resume-play` runs after the marker's arming is confirmed and before `--trigger` is dispatched: it resumes PlayMode only when PlayMode is actually paused, and reports what it did in `ResumePlayResult` (`WasPaused` / `Resumed` / `Error`). If the resume fails, the trigger is not dispatched and `TriggerResult.Error` says so. When the game reaches the line on its own after resuming (gravity, physics), omit `--trigger` and keep `--resume-play`. +`--resume-play` runs after the marker's arming is confirmed and before `--trigger` is dispatched: it resumes PlayMode only when PlayMode is actually paused, and reports what it did in `ResumePlayResult` (`WasPaused` / `Resumed` / `Error`; an abandoned wait adds `Repaused` / `RepauseError`). If the resume fails, the trigger is not dispatched and `TriggerResult.Error` says so. If the trigger itself is rejected before it runs, the wait is abandoned and the resume is undone: `Repaused: true` (or `RepauseError`) reports PlayMode being paused again, so gameplay cannot consume the preserved marker while the trigger value is being fixed. When the game reaches the line on its own after resuming (gravity, physics), omit `--trigger` and keep `--resume-play`. ## Do Not Use Time.timeScale = 0 diff --git a/Packages/src/Editor/FirstPartyTools/Common/InputRecording/InputRecordingFileHelper.cs b/Packages/src/Editor/FirstPartyTools/Common/InputRecording/InputRecordingFileHelper.cs index 90e5df343e..f7c77af1f0 100644 --- a/Packages/src/Editor/FirstPartyTools/Common/InputRecording/InputRecordingFileHelper.cs +++ b/Packages/src/Editor/FirstPartyTools/Common/InputRecording/InputRecordingFileHelper.cs @@ -92,14 +92,20 @@ public static string ResolveLatestRecording(string inputPath) return files.OrderByDescending(f => File.GetLastWriteTimeUtc(f)).First(); } - public static HashSet? ParseKeyFilter(string keys) + /// + /// Parses the comma-separated key filter into the keys to record. Entries that name no key + /// are reported rather than skipped: dropping them silently would record a different set of + /// keys than the caller asked for, and dropping all of them would record every key. + /// + public static KeyFilterParseResult ParseKeyFilter(string keys) { if (string.IsNullOrEmpty(keys)) { - return null; + return new KeyFilterParseResult(null, Array.Empty()); } HashSet filter = new(); + List invalidKeyNames = new(); string[] parts = keys.Split(','); for (int i = 0; i < parts.Length; i++) @@ -110,17 +116,26 @@ public static string ResolveLatestRecording(string inputPath) continue; } - if (Enum.TryParse(trimmed, ignoreCase: true, out Key key) && key != Key.None) - { - filter.Add(key); - } - else + (bool resolved, Key key) = KeyNameResolver.Resolve(trimmed); + if (!resolved) { - Debug.LogWarning($"[InputRecordingFileHelper] Unknown key name in filter: '{trimmed}'"); + invalidKeyNames.Add(trimmed); + continue; } + + filter.Add(key); + } + + if (filter.Count == 0 && invalidKeyNames.Count == 0) + { + // Every entry was empty (for example "," or " "), so the filter would fall back to + // recording every key while the response looked like no filter was ever given. + // Why not reject the empty entries themselves: a trailing comma in "W," is harmless + // once at least one entry names a key. + invalidKeyNames.Add(keys); } - return filter.Count > 0 ? filter : null; + return new KeyFilterParseResult(filter.Count > 0 ? filter : null, invalidKeyNames); } } } diff --git a/Packages/src/Editor/FirstPartyTools/Common/InputRecording/KeyFilterParseResult.cs b/Packages/src/Editor/FirstPartyTools/Common/InputRecording/KeyFilterParseResult.cs new file mode 100644 index 0000000000..0c1abaa2e2 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/InputRecording/KeyFilterParseResult.cs @@ -0,0 +1,27 @@ +#if ULOOP_HAS_INPUT_SYSTEM +#nullable enable +using System.Collections.Generic; +using UnityEngine.InputSystem; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Outcome of parsing a comma-separated key filter: the keys to record, plus every entry that + /// named no key. Why carry the rejected entries instead of dropping them: a filter that lost + /// entries records something other than what was asked for, and the caller cannot tell that + /// from a filter that was never given. + /// + internal sealed class KeyFilterParseResult + { + public KeyFilterParseResult(HashSet? filter, IReadOnlyList invalidKeyNames) + { + Filter = filter; + InvalidKeyNames = invalidKeyNames; + } + + public HashSet? Filter { get; } + + public IReadOnlyList InvalidKeyNames { get; } + } +} +#endif diff --git a/Packages/src/Editor/FirstPartyTools/Common/InputRecording/KeyFilterParseResult.cs.meta b/Packages/src/Editor/FirstPartyTools/Common/InputRecording/KeyFilterParseResult.cs.meta new file mode 100644 index 0000000000..e816f6faac --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/InputRecording/KeyFilterParseResult.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e6ed0f03d8bd4407691333639c5611fa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Common/InputSystem/KeyNameResolver.cs b/Packages/src/Editor/FirstPartyTools/Common/InputSystem/KeyNameResolver.cs new file mode 100644 index 0000000000..3de90c343c --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/InputSystem/KeyNameResolver.cs @@ -0,0 +1,68 @@ +#if ULOOP_HAS_INPUT_SYSTEM +using System; +using System.Collections.Generic; +using UnityEngine.InputSystem; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Resolves Input System Key values from the key names tools accept, so every tool applies the + /// same rule for what counts as a key name. + /// + internal static class KeyNameResolver + { + // Immutable name-to-value map of the Input System Key enum, so key resolution never falls + // back to Enum.TryParse's ordinal and flag-combination behavior. + private static readonly IReadOnlyDictionary DefinedKeysByName = BuildDefinedKeysByName(); + + /// + /// Resolves a raw key name to its Key value, reporting whether it named a key at all. + /// + public static (bool resolved, Key key) Resolve(string keyName) + { + // Why not Enum.TryParse: it also accepts ordinals ("3"), signed ordinals ("+3"), + // whitespace-padded input, comma-separated names OR-ed together ("Space,Enter"), and + // undefined ordinals ("300") that later throw from the keyboard indexer. Only a name + // that is defined on the Key enum may resolve to a key. + string normalizedKey = NormalizeKeyName(keyName); + if (!DefinedKeysByName.TryGetValue(normalizedKey, out Key key) || key == Key.None) + { + return (false, Key.None); + } + + return (true, key); + } + + /// + /// Trims the raw name and applies the Return alias, giving callers the form the whitelist + /// is keyed by so their diagnostics can describe the same value that was looked up. + /// + public static string NormalizeKeyName(string keyName) + { + // Why trim here rather than at the whitelist comparison: Enum.TryParse used to accept + // whitespace-padded names, so padded correct input already worked. Blocking ambiguous + // input must not narrow correct input, and the alias has to see the padded form too. + string trimmed = keyName.Trim(); + if (string.Equals(trimmed, "Return", StringComparison.OrdinalIgnoreCase)) + { + return Key.Enter.ToString(); + } + + return trimmed; + } + + private static IReadOnlyDictionary BuildDefinedKeysByName() + { + string[] names = Enum.GetNames(typeof(Key)); + Array values = Enum.GetValues(typeof(Key)); + Dictionary keysByName = new(names.Length, StringComparer.OrdinalIgnoreCase); + for (int index = 0; index < names.Length; index++) + { + keysByName[names[index]] = (Key)values.GetValue(index); + } + + return keysByName; + } + } +} +#endif diff --git a/Packages/src/Editor/FirstPartyTools/Common/InputSystem/KeyNameResolver.cs.meta b/Packages/src/Editor/FirstPartyTools/Common/InputSystem/KeyNameResolver.cs.meta new file mode 100644 index 0000000000..79bba0de6f --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/InputSystem/KeyNameResolver.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e3c265151ed404bb1aa2e42f44205e01 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Common/Preflight/PlayModeToolPreflightResult.cs b/Packages/src/Editor/FirstPartyTools/Common/Preflight/PlayModeToolPreflightResult.cs new file mode 100644 index 0000000000..6a7e908832 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/Preflight/PlayModeToolPreflightResult.cs @@ -0,0 +1,54 @@ +#nullable enable +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Outcome of a PlayMode preflight check: whether the tool may run, the wire-visible rejection + /// message, and — when a pause point is what refused the call — that pause point's id as a + /// separate field. The id exists on its own because callers (the CLI's --trigger diagnosis + /// above all) have to tell "refused by the marker I am waiting on" from "refused for some other + /// reason", and the message text is too brittle to match on. + /// + public class PlayModeToolPreflightResult + { + public bool IsValid { get; } + + /// Rejection message, empty on success. Never null, so callers can assign it to a + /// tool response's non-nullable Message without a null check. + public string ErrorMessage { get; } + + /// Id of the pause point holding PlayMode paused, null for every other outcome. + public string? RejectedByActivePausePointId { get; } + + private PlayModeToolPreflightResult(bool isValid, string errorMessage, string? rejectedByActivePausePointId) + { + IsValid = isValid; + ErrorMessage = errorMessage; + RejectedByActivePausePointId = rejectedByActivePausePointId; + } + + /// Creates the success outcome. + public static PlayModeToolPreflightResult Success() + { + return new PlayModeToolPreflightResult(true, string.Empty, null); + } + + /// Creates a rejection that has no pause point behind it. + public static PlayModeToolPreflightResult Failure(string errorMessage) + { + Debug.Assert(!string.IsNullOrEmpty(errorMessage), "errorMessage must not be null or empty"); + return new PlayModeToolPreflightResult(false, errorMessage, null); + } + + /// Creates a rejection caused by an active pause point, recording which one. + public static PlayModeToolPreflightResult FailureRejectedByPausePoint( + string errorMessage, + string pausePointId) + { + Debug.Assert(!string.IsNullOrEmpty(errorMessage), "errorMessage must not be null or empty"); + Debug.Assert(!string.IsNullOrEmpty(pausePointId), "pausePointId must not be null or empty"); + return new PlayModeToolPreflightResult(false, errorMessage, pausePointId); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Common/Preflight/PlayModeToolPreflightResult.cs.meta b/Packages/src/Editor/FirstPartyTools/Common/Preflight/PlayModeToolPreflightResult.cs.meta new file mode 100644 index 0000000000..1a1047e344 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/Preflight/PlayModeToolPreflightResult.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b6c9bb51e0b3d4cd4b1d88aa86b681b2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Common/Preflight/PlayModeToolPreflightService.cs b/Packages/src/Editor/FirstPartyTools/Common/Preflight/PlayModeToolPreflightService.cs index c09fa2e183..fe05f3a5b1 100644 --- a/Packages/src/Editor/FirstPartyTools/Common/Preflight/PlayModeToolPreflightService.cs +++ b/Packages/src/Editor/FirstPartyTools/Common/Preflight/PlayModeToolPreflightService.cs @@ -3,7 +3,6 @@ using UnityEngine; using io.github.hatayama.UnityCliLoop.Runtime; -using io.github.hatayama.UnityCliLoop.ToolContracts; namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { @@ -19,39 +18,60 @@ public static class PlayModeToolPreflightService /// /// Fails when PlayMode is not active. Use for tools that tolerate paused PlayMode. /// - public static ValidationResult RequireActive() + public static PlayModeToolPreflightResult RequireActive() { if (!EditorApplication.isPlaying) { - return ValidationResult.Failure(PlayModeNotActiveMessage); + return PlayModeToolPreflightResult.Failure(PlayModeNotActiveMessage); } - return ValidationResult.Success(); + return PlayModeToolPreflightResult.Success(); } /// /// Fails when PlayMode is not active, or is active but paused. The paused-message suffix /// describes the blocked action in the caller's vocabulary (for example "recording input"). /// - public static ValidationResult RequireActiveAndNotPaused(string pausedActionDescription) + public static PlayModeToolPreflightResult RequireActiveAndNotPaused(string pausedActionDescription) + { + return Evaluate( + EditorApplication.isPlaying, + EditorApplication.isPaused, + UloopPausePointRegistry.GetActivePausePointId(), + pausedActionDescription); + } + + /// + /// Decides a paused-aware preflight outcome from already-read editor state. Separated from + /// RequireActiveAndNotPaused so the paused branches — the only ones that produce a pause + /// point id — are reachable from EditMode tests, where PlayMode is never actually running. + /// + public static PlayModeToolPreflightResult Evaluate( + bool isPlaying, + bool isPaused, + string activePausePointId, + string pausedActionDescription) { Debug.Assert(!string.IsNullOrEmpty(pausedActionDescription), "pausedActionDescription must not be null or empty"); - if (!EditorApplication.isPlaying) + if (!isPlaying) + { + return PlayModeToolPreflightResult.Failure(PlayModeNotActiveMessage); + } + + if (!isPaused) { - return ValidationResult.Failure(PlayModeNotActiveMessage); + return PlayModeToolPreflightResult.Success(); } - if (EditorApplication.isPaused) + if (string.IsNullOrEmpty(activePausePointId)) { - string activePausePointId = UloopPausePointRegistry.GetActivePausePointId(); - string message = string.IsNullOrEmpty(activePausePointId) - ? FormatPausedMessage(pausedActionDescription) - : FormatPausePointPausedMessage(activePausePointId, pausedActionDescription); - return ValidationResult.Failure(message); + return PlayModeToolPreflightResult.Failure(FormatPausedMessage(pausedActionDescription)); } - return ValidationResult.Success(); + return PlayModeToolPreflightResult.FailureRejectedByPausePoint( + FormatPausePointPausedMessage(activePausePointId, pausedActionDescription), + activePausePointId); } /// diff --git a/Packages/src/Editor/FirstPartyTools/Compile/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/Compile/Skill/SKILL.md index 108abc5a84..3febf89300 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/Compile/Skill/SKILL.md @@ -18,7 +18,7 @@ uloop compile [--force-recompile] [--no-wait-for-domain-reload] [--stop-on-exter | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--force-recompile` | flag | - | Full recompile plus domain reload. Rarely needed — see "When to use --force-recompile" below | +| `--force-recompile` | flag | - | Full recompile plus domain reload. Almost never needed: a plain compile already detects externally edited files, and the forced reload can freeze large projects and come back as `COMPILE_RESULT_UNKNOWN`. | | `--no-wait-for-domain-reload` | flag | - | Return before Domain Reload completion | | `--stop-on-external-scene-changes` | flag | - | Stop before compilation if open Scene files changed externally instead of auto-reloading them | diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/SKILL.md index 07a8b91604..102b90f0e2 100644 --- a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/SKILL.md @@ -1,7 +1,7 @@ --- name: uloop-control-play-mode toolName: control-play-mode -description: "Control Unity Editor Play Mode. Use to start, stop, pause, or step Play Mode, or query its state without side effects, for runtime behavior checks and frame inspection." +description: "Control Unity Editor Play Mode. Use to Play (or Resume, its alias), Stop, Pause, or Step Play Mode, or query Status without side effects, for runtime behavior checks and frame inspection." --- # uloop control-play-mode @@ -18,7 +18,7 @@ uloop control-play-mode [options] | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | string | `Play` | Action to perform: `Play`, `Stop`, `Pause`, `Step`, `Status`, `Resume` (alias of `Play`) | +| `--action` | string | `Play` | `Play` - start Play Mode, `Stop` - stop Play Mode, `Pause` - pause Play Mode, `Step` - advance one frame while paused, `Status` - report current state without changing anything, `Resume` - alias of Play in every state, including starting Play Mode when stopped | | `--timeout-seconds` | integer | `180` | Maximum seconds to wait for the requested play mode state | ## Output diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/SKILL.md index 23aec2b682..f02d3336d9 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/SKILL.md @@ -16,10 +16,16 @@ Live state injection: when a running PlayMode session is merely in the wrong sta ## Parameters -- `--code ''`: Inline C# statements to execute. Use direct statements only; `return` is optional, and `using` directives may appear at the top of the snippet. +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--code` | string | - | Inline C# statements to execute. Direct statements only; `return` is optional, and `using` directives may appear at the top of the snippet. | +| `--parameters` | object | - | Shell-quoted JSON object literal for reusing a snippet with varying data or keeping values outside the code. Values are exposed as `parameters["param0"]`, `parameters["param1"]`, and so on. Omit for most snippets; never pass a JSON string value. | +| `--wait-for-domain-reload` | flag | - | Wait for Domain Reload recovery after snippets that intentionally trigger Unity script reload or import work. Omit for normal inspection and editor-state workflows. | +| `--yield-to-foreground-requests` | flag | - | Allow foreground requests to preempt this execution | + +CLI-only flag, accepted instead of a schema parameter: + - `--code-file `: Read the C# statements from a file instead of `--code`. Use this when the active shell or launcher cannot preserve inline code exactly. Exactly one of `--code` or `--code-file` is required; combining them is an error. -- `--parameters {}` (advanced, optional): Pass a shell-quoted JSON object literal 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. Do not pass a JSON string value such as `"{\"param0\":\"value\"}"`. -- `--wait-for-domain-reload` (optional): Wait for Domain Reload recovery after snippets that intentionally trigger Unity script reload or import work. Omit this for normal inspection and editor-state workflows. ## Code Rules diff --git a/Packages/src/Editor/FirstPartyTools/RecordInput/RecordInputResponse.cs b/Packages/src/Editor/FirstPartyTools/RecordInput/RecordInputResponse.cs index b0f770acab..ed1eee1ac0 100644 --- a/Packages/src/Editor/FirstPartyTools/RecordInput/RecordInputResponse.cs +++ b/Packages/src/Editor/FirstPartyTools/RecordInput/RecordInputResponse.cs @@ -14,5 +14,13 @@ public class RecordInputResponse : UnityCliLoopToolResponse public string? OutputPath { get; set; } public int? TotalFrames { get; set; } public float? DurationSeconds { get; set; } + + /// + /// Id of the pause point that refused this call before it ran anything, null otherwise. A + /// refusal means nothing was started, so a caller reading only Success would miss that the + /// action never happened. The CLI's --trigger diagnosis compares this against the marker it + /// awaits. + /// + public string? RejectedByActivePausePointId { get; set; } } } diff --git a/Packages/src/Editor/FirstPartyTools/RecordInput/RecordInputUseCase.cs b/Packages/src/Editor/FirstPartyTools/RecordInput/RecordInputUseCase.cs index 44b1b66f3b..dda4f02e88 100644 --- a/Packages/src/Editor/FirstPartyTools/RecordInput/RecordInputUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/RecordInput/RecordInputUseCase.cs @@ -98,14 +98,15 @@ private static async Task ExecuteStartAsync( RecordInputSchema request, CancellationToken ct) { - ValidationResult preflight = PlayModeToolPreflightService.RequireActiveAndNotPaused(PausedActionDescription); + PlayModeToolPreflightResult preflight = PlayModeToolPreflightService.RequireActiveAndNotPaused(PausedActionDescription); if (!preflight.IsValid) { return new RecordInputResponse { Success = false, Message = preflight.ErrorMessage, - Action = RecordInputAction.Start.ToString() + Action = RecordInputAction.Start.ToString(), + RejectedByActivePausePointId = preflight.RejectedByActivePausePointId }; } @@ -140,7 +141,23 @@ private static async Task ExecuteStartAsync( } int delaySeconds = Mathf.Clamp(request.DelaySeconds, RecordInputConstants.MIN_DELAY_SECONDS, RecordInputConstants.MAX_DELAY_SECONDS); - HashSet? keyFilter = InputRecordingFileHelper.ParseKeyFilter(request.Keys); + KeyFilterParseResult keyFilterResult = InputRecordingFileHelper.ParseKeyFilter(request.Keys); + if (keyFilterResult.InvalidKeyNames.Count > 0) + { + // Why reject instead of recording what did parse: a recording is taken once, and a + // filter that quietly lost entries produces a file that looks like the requested + // one. Entries that all failed would record every key, the opposite of the request. + return new RecordInputResponse + { + Success = false, + Message = + $"Invalid key name(s) in the keys filter: {string.Join(", ", keyFilterResult.InvalidKeyNames)}. " + + "Use Input System Key enum names (e.g. \"W\", \"Space\", \"LeftShift\", \"Digit3\").", + Action = RecordInputAction.Start.ToString() + }; + } + + HashSet? keyFilter = keyFilterResult.Filter; if (request.ShowOverlay) { diff --git a/Packages/src/Editor/FirstPartyTools/RecordInput/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/RecordInput/Skill/SKILL.md index 43f966dc3d..d3481797b3 100644 --- a/Packages/src/Editor/FirstPartyTools/RecordInput/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/RecordInput/Skill/SKILL.md @@ -28,9 +28,11 @@ uloop record-input --action Stop --output-path scripts/my-play.json | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | enum | `Start` | `Start` - begin recording, `Stop` - stop and save | -| `--output-path` | string | auto | Save path. Auto-generates under `.uloop/outputs/InputRecordings/` | -| `--keys` | string | `""` | Comma-separated key filter. Empty = all common game keys | +| `--action` | enum | `Start` | `Start` - begin recording input, `Stop` - stop recording and save to file | +| `--output-path` | string | auto | Save path for the recording JSON. When empty, auto-generates under `.uloop/outputs/InputRecordings/` | +| `--keys` | string | `""` | Comma-separated key filter of Input System Key enum names (for example `W,A,S,D,Space`). Case-insensitive. Digit keys use `Digit0`-`Digit9` or `Numpad0`-`Numpad9`, not bare `0`-`9`; a name that matches no key fails the command instead of being dropped from the filter. Empty records all common game keys | +| `--delay-seconds` | integer | `3` | Countdown delay in seconds before recording starts (0-10). Gives time to switch focus to Game View. | +| `--no-show-overlay` | flag | - | Hide the recording countdown and REC indicator overlay | ## Deterministic Replay diff --git a/Packages/src/Editor/FirstPartyTools/ReplayInput/ReplayInputResponse.cs b/Packages/src/Editor/FirstPartyTools/ReplayInput/ReplayInputResponse.cs index 7b50abd38f..773aec61e8 100644 --- a/Packages/src/Editor/FirstPartyTools/ReplayInput/ReplayInputResponse.cs +++ b/Packages/src/Editor/FirstPartyTools/ReplayInput/ReplayInputResponse.cs @@ -16,5 +16,13 @@ public class ReplayInputResponse : UnityCliLoopToolResponse public int? TotalFrames { get; set; } public float? Progress { get; set; } public bool? IsReplaying { get; set; } + + /// + /// Id of the pause point that refused this call before it ran anything, null otherwise. A + /// refusal means nothing was started, so a caller reading only Success would miss that the + /// action never happened. The CLI's --trigger diagnosis compares this against the marker it + /// awaits. + /// + public string? RejectedByActivePausePointId { get; set; } } } diff --git a/Packages/src/Editor/FirstPartyTools/ReplayInput/ReplayInputUseCase.cs b/Packages/src/Editor/FirstPartyTools/ReplayInput/ReplayInputUseCase.cs index 9e3ce27179..d23f144e15 100644 --- a/Packages/src/Editor/FirstPartyTools/ReplayInput/ReplayInputUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/ReplayInput/ReplayInputUseCase.cs @@ -94,14 +94,15 @@ public async Task ReplayInputAsync( #if ULOOP_HAS_INPUT_SYSTEM private static ReplayInputResponse ExecuteStart(ReplayInputSchema request) { - ValidationResult preflight = PlayModeToolPreflightService.RequireActiveAndNotPaused(PausedActionDescription); + PlayModeToolPreflightResult preflight = PlayModeToolPreflightService.RequireActiveAndNotPaused(PausedActionDescription); if (!preflight.IsValid) { return new ReplayInputResponse { Success = false, Message = preflight.ErrorMessage, - Action = ReplayInputAction.Start.ToString() + Action = ReplayInputAction.Start.ToString(), + RejectedByActivePausePointId = preflight.RejectedByActivePausePointId }; } diff --git a/Packages/src/Editor/FirstPartyTools/ReplayInput/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/ReplayInput/Skill/SKILL.md index 051b96759c..1ae5450c37 100644 --- a/Packages/src/Editor/FirstPartyTools/ReplayInput/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/ReplayInput/Skill/SKILL.md @@ -31,8 +31,8 @@ uloop replay-input --action Stop | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | enum | `Start` | `Start`, `Stop`, `Status` | -| `--input-path` | string | auto | JSON path. Auto-detects latest in `.uloop/outputs/InputRecordings/` | +| `--action` | enum | `Start` | `Start` - begin replaying, `Stop` - stop mid-way, `Status` - check progress | +| `--input-path` | string | auto | Path to the recording JSON. When empty, auto-detects the latest recording in `.uloop/outputs/InputRecordings/` | | `--no-show-overlay` | flag | - | Hide replay progress overlay | | `--loop` | flag | - | Loop continuously | diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/SKILL.md index 942de5c6cf..435880c53b 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/SKILL.md @@ -18,12 +18,12 @@ uloop screenshot [--window-name ] [--resolution-scale ] [--match-mo | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--window-name` | string | `Game` | Window name to capture. Ignored when `--capture-mode rendering`. When the Game tab is Device Simulator and the title is `Simulator`, default `Game` falls back to `Simulator`. | +| `--window-name` | string | `Game` | Window name to capture (for example `Game`, `Scene`, `Console`, `Inspector`). Ignored when `--capture-mode rendering`. When the Game tab is Device Simulator and the title is Simulator, default Game falls back to Simulator. | | `--resolution-scale` | number | `1.0` | Resolution scale (0.1 to 1.0) | | `--match-mode` | enum | `exact` | Window name matching mode: `exact`, `prefix`, or `contains`. Ignored when `--capture-mode rendering`. | -| `--capture-mode` | enum | `window` | `window`=capture EditorWindow including toolbar, `rendering`=capture game rendering only (PlayMode required, coordinates match simulate-mouse) | +| `--capture-mode` | enum | `window` | `window` - capture EditorWindow including toolbar, `rendering` - capture game rendering only (PlayMode required). Rendering screenshots return `ScreenshotToInputFormula` for converting raw image pixels before calling simulate-mouse-input or raycast. | | `--output-directory` | string | `""` | Output directory path for saving screenshots. When empty, uses default path (.uloop/outputs/Screenshots/). Accepts absolute paths. | -| `--annotate-elements` | flag | - | Annotate interactive UI elements with index labels and interaction hints (A / CLICK, B / DRAG, ...). Only works with `--capture-mode rendering` in PlayMode. | +| `--annotate-elements` | flag | - | Annotate interactive UI elements with index labels and interaction hints (A / CLICK, B / DRAG, ...). The response includes an `AnnotatedElements` array with element metadata sorted by z-order. Only works with `--capture-mode rendering` in PlayMode. | | `--annotate-raycast-grid` | flag | - | Annotate clustered 3D physics collider candidates as `PhysicsCollider` entries in `AnnotatedElements`. Uses `Camera.main` visibility and the same top-left Game View coordinates as `simulate-mouse-input`. Only works with `--capture-mode rendering` in PlayMode. | | `--raycast-layer-mask` | string | `""` | Comma-separated physics layer names to narrow which layers `--annotate-raycast-grid` clusters. Hits are limited to layers also visible to `Camera.main.cullingMask`. When omitted, clusters against `Physics.DefaultRaycastLayers`. | | `--elements-only` | flag | - | Return only annotated element JSON without capturing a screenshot image. Requires `--annotate-elements` or `--annotate-raycast-grid`, and `--capture-mode rendering` in PlayMode. | diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/KeyboardKeyNameSuggester.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/KeyboardKeyNameSuggester.cs index fdbfa27bfb..9c4ec3dbf0 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/KeyboardKeyNameSuggester.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/KeyboardKeyNameSuggester.cs @@ -26,7 +26,9 @@ public static IReadOnlyList Suggest(string invalidKeyName) string trimmed = invalidKeyName.Trim(); List suggestions = new(); - if (trimmed.Length == 1 && char.IsDigit(trimmed[0])) + // Why not char.IsDigit: it is true for non-ASCII digits such as "3", which have no + // Digit/Numpad enum name, so suggesting them would point at keys that do not exist. + if (trimmed.Length == 1 && trimmed[0] >= '0' && trimmed[0] <= '9') { string digit = trimmed; AddUnique(suggestions, $"Digit{digit}"); diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs index 52d1151b42..b9ff6b3c7c 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs @@ -15,6 +15,14 @@ public class SimulateKeyboardResponse : UnityCliLoopToolResponse public string Action { get; set; } = ""; public string? KeyName { get; set; } public bool InterruptedByPausePoint { get; set; } + /// + /// Id of the pause point that refused this call before it ran anything, null otherwise. + /// Distinct from PausePointId, which reports a marker hit *during* the call: a refusal means + /// no input was injected at all, so a caller reading only Success would miss that the action + /// never happened. The CLI's --trigger diagnosis compares this against the marker it awaits. + /// + public string? RejectedByActivePausePointId { get; set; } + public string? PausePointId { get; set; } public int? PausePointHitCount { get; set; } public List? PausePointHits { get; set; } diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs index 780e512a86..5fbce21030 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs @@ -1,5 +1,4 @@ #nullable enable -using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -49,7 +48,7 @@ public async Task ExecuteAsync( // ReleaseAll must work while paused so agents can recover stuck device state after a // pause-point interruption without first resuming PlayMode. - ValidationResult preflight = parameters.Action == UnityCliLoopKeyboardAction.ReleaseAll + PlayModeToolPreflightResult preflight = parameters.Action == UnityCliLoopKeyboardAction.ReleaseAll ? PlayModeToolPreflightService.RequireActive() : PlayModeToolPreflightService.RequireActiveAndNotPaused(PausedActionDescription); if (!preflight.IsValid) @@ -58,7 +57,8 @@ public async Task ExecuteAsync( { Success = false, Message = preflight.ErrorMessage, - Action = parameters.Action.ToString() + Action = parameters.Action.ToString(), + RejectedByActivePausePointId = preflight.RejectedByActivePausePointId }; } @@ -72,23 +72,31 @@ public async Task ExecuteAsync( return new SimulateKeyboardResponse { Success = false, - Message = "Key parameter is required. Examples: \"W\", \"Space\", \"LeftShift\", \"A\", \"Enter\".", + Message = "Key parameter is required. Examples: \"W\", \"Space\", \"LeftShift\", \"A\", \"Enter\", \"Digit3\".", Action = parameters.Action.ToString() }; } - string normalizedKey = NormalizeKeyName(parameters.Key); - if (!Enum.TryParse(normalizedKey, ignoreCase: true, out Key key) || key == Key.None) + (bool resolved, Key key) = KeyNameResolver.Resolve(parameters.Key); + if (!resolved) { - IReadOnlyList suggestions = KeyboardKeyNameSuggester.Suggest(parameters.Key); + // Suggest from the normalized form so padding does not degrade the candidates, + // while the message below still reports the raw input verbatim. + string normalizedKey = KeyNameResolver.NormalizeKeyName(parameters.Key); + IReadOnlyList suggestions = KeyboardKeyNameSuggester.Suggest(normalizedKey); string suggestionText = suggestions.Count == 0 ? string.Empty : $" Did you mean: {string.Join(", ", suggestions)}?"; + // Why: digits used to resolve silently to unrelated keys, so earlier runs that + // reported success may have pressed something else and need to be re-checked. + string ordinalHistoryText = LooksLikeNumericKeyInput(parameters.Key) + ? " Digits are not key names: bare digits were previously parsed as enum ordinals (e.g. \"3\" pressed Tab), so re-check any earlier results or scripts that passed digits." + : string.Empty; return new SimulateKeyboardResponse { Success = false, Message = - $"Invalid key name: \"{parameters.Key}\". Use Input System Key enum names (e.g. \"W\", \"Space\", \"LeftShift\", \"A\", \"Enter\").{suggestionText}", + $"Invalid key name: \"{parameters.Key}\". Use Input System Key enum names (e.g. \"W\", \"Space\", \"LeftShift\", \"A\", \"Enter\", \"Digit3\").{suggestionText}{ordinalHistoryText}", Action = parameters.Action.ToString() }; } @@ -230,15 +238,37 @@ private static void EnsureOverlayExists() OverlayCanvasFactory.EnsureExists(); } - private static string NormalizeKeyName(string keyName) + /// + /// Reports whether the raw key input is the numeric form that Enum.TryParse used to accept + /// as an enum ordinal, so the rejection can explain what earlier runs actually pressed. + /// + private static bool LooksLikeNumericKeyInput(string keyName) { - if (string.Equals(keyName, "Return", StringComparison.OrdinalIgnoreCase)) + string trimmed = keyName.Trim(); + if (trimmed.Length > 0 && (trimmed[0] == '+' || trimmed[0] == '-')) + { + trimmed = trimmed.Substring(1); + } + + if (trimmed.Length == 0) { - return Key.Enter.ToString(); + return false; } - return keyName; + + for (int index = 0; index < trimmed.Length; index++) + { + // Why not char.IsDigit: it is true for non-ASCII digits, which Enum.TryParse never + // accepted as ordinals. Claiming they used to press another key would be false. + if (trimmed[index] < '0' || trimmed[index] > '9') + { + return false; + } + } + + return true; } + #endif } } diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md index c3e601ce69..fd8b1887ab 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md @@ -1,7 +1,7 @@ --- name: uloop-simulate-keyboard toolName: simulate-keyboard -description: "Simulate keyboard input in PlayMode through Unity Input System. Use for key presses, holds, releases, and game controls such as WASD or Space." +description: "Simulate keyboard input in PlayMode through Unity Input System. Use for key presses, holds (via Press --duration or KeyDown/KeyUp), releases, and game controls such as WASD or Space. Requires the Input System package (com.unity.inputsystem)." --- # Task @@ -27,7 +27,7 @@ uloop simulate-keyboard --action ReleaseAll | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | enum | `Press` | `Press`, `KeyDown`, `KeyUp`, `ReleaseAll` | +| `--action` | enum | `Press` | `Press` - one-shot key tap (Down then Up), `KeyDown` - hold key down, `KeyUp` - release held key, `ReleaseAll` - force-release every tracked and device-pressed key (allowed while PlayMode is paused; use after a pause-point interruption leaves key state inconsistent) | | `--key` | string | (required except `ReleaseAll`) | Key name matching Input System Key enum (e.g. `W`, `Space`, `LeftShift`, `A`, `Enter`). Case-insensitive. Digit keys use `Digit0`-`Digit9` or `Numpad0`-`Numpad9`, not bare `0`-`9`. Not used by `ReleaseAll`. | | `--duration` | number | `0` | Hold duration in seconds for Press action (0 = one-shot tap). Ignored by KeyDown/KeyUp/ReleaseAll. | diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputResponse.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputResponse.cs index 72982359eb..71cd0e8ab9 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputResponse.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputResponse.cs @@ -26,6 +26,14 @@ public class SimulateMouseInputResponse : UnityCliLoopToolResponse public float? InjectedUnityPositionY { get; set; } public string CoordinateConversionFormula { get; set; } = ""; public bool InterruptedByPausePoint { get; set; } + /// + /// Id of the pause point that refused this call before it ran anything, null otherwise. + /// Distinct from PausePointId, which reports a marker hit *during* the call: a refusal means + /// no input was injected at all, so a caller reading only Success would miss that the action + /// never happened. The CLI's --trigger diagnosis compares this against the marker it awaits. + /// + public string? RejectedByActivePausePointId { get; set; } + public string? PausePointId { get; set; } public int? PausePointHitCount { get; set; } public List? PausePointHits { get; set; } diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs index 9bc0fa565d..ff007ac80f 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs @@ -46,14 +46,15 @@ public async Task ExecuteAsync( #else string correlationId = UnityCliLoopConstants.GenerateCorrelationId(); - ValidationResult preflight = PlayModeToolPreflightService.RequireActiveAndNotPaused(PausedActionDescription); + PlayModeToolPreflightResult preflight = PlayModeToolPreflightService.RequireActiveAndNotPaused(PausedActionDescription); if (!preflight.IsValid) { return new SimulateMouseInputResponse { Success = false, Message = preflight.ErrorMessage, - Action = parameters.Action.ToString() + Action = parameters.Action.ToString(), + RejectedByActivePausePointId = preflight.RejectedByActivePausePointId }; } diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md index a4029fe698..cc6a406c73 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md @@ -1,7 +1,7 @@ --- name: uloop-simulate-mouse-input toolName: simulate-mouse-input -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." +description: "Simulate Mouse.current input in PlayMode through Unity Input System. Use for gameplay mouse clicks, long-press (LongPress), movement delta (MoveDelta/SmoothDelta), or scroll. Use simulate-mouse-ui for UI. Requires the Input System package and Active Input Handling set to 'Input System Package (New)' or 'Both'." --- # Task @@ -17,6 +17,11 @@ Simulate mouse input via Input System in Unity PlayMode. 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 +Two rules while verifying: + +- Do not touch the physical mouse or keyboard, and keep the OS pointer off the Unity window — real device input mixes into the same `Mouse.current` state this tool injects. +- Read the starting values you will assert against immediately before firing the input; a value measured earlier in the session may have changed. + ## Tool Reference ```bash @@ -27,7 +32,7 @@ uloop simulate-mouse-input --action [options] | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | enum | `Click` | `Click`, `LongPress`, `MoveDelta`, `SmoothDelta`, `Scroll` | +| `--action` | enum | `Click` | `Click` - inject button press+release, `LongPress` - inject button hold for `--duration` seconds, `MoveDelta` - inject mouse delta (one-shot), `SmoothDelta` - inject mouse delta smoothly over `--duration` seconds, `Scroll` - inject scroll wheel | | `--x` | number | `0` | Target X position in Game View pixels (origin: top-left). Used by Click and LongPress. Use `AnnotatedElements[].SimX`, or raw image pixels converted with `ScreenshotToInputFormula`. | | `--y` | number | `0` | Target Y position in Game View pixels (origin: top-left). Used by Click and LongPress. Use `AnnotatedElements[].SimY`, or raw image pixels converted with `ScreenshotToInputFormula`. | | `--button` | enum | `Left` | Mouse button: `Left`, `Right`, `Middle`. Used by Click and LongPress. | @@ -35,7 +40,7 @@ uloop simulate-mouse-input --action [options] | `--delta-x` | number | `0` | Delta X in pixels for MoveDelta/SmoothDelta. Positive = right. | | `--delta-y` | number | `0` | Delta Y in pixels for MoveDelta/SmoothDelta. Positive = up. | | `--scroll-x` | number | `0` | Horizontal scroll delta for Scroll action. | -| `--scroll-y` | number | `0` | Vertical scroll delta for Scroll action. Typically 120 per notch. | +| `--scroll-y` | number | `0` | Vertical scroll delta for Scroll action. Positive = up, negative = down. Typically 120 per notch. | ### Actions diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiSimulationResponseFactory.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiSimulationResponseFactory.cs index 6e739c7c0c..356eead17d 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiSimulationResponseFactory.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiSimulationResponseFactory.cs @@ -24,6 +24,25 @@ internal static SimulateMouseUiResponse CreateFailure( }; } + /// + /// Creates the failure response for a rejected PlayMode preflight, carrying the rejecting + /// pause point's id through to the wire. Separate from CreateFailure (rather than an optional + /// argument on it) so the other failure sites cannot accidentally claim a pause-point cause. + /// + internal static SimulateMouseUiResponse CreatePreflightFailure( + MouseUiSimulationCommand parameters, + PlayModeToolPreflightResult preflight) + { + Debug.Assert(!preflight.IsValid, "CreatePreflightFailure must only be called for a rejected preflight"); + return new SimulateMouseUiResponse + { + Success = false, + Message = preflight.ErrorMessage, + Action = parameters.Action.ToString(), + RejectedByActivePausePointId = preflight.RejectedByActivePausePointId + }; + } + internal static SimulateMouseUiResponse CreateFrameTimeoutResult( MouseAction action, Vector2 position, diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiSimulationValidator.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiSimulationValidator.cs index e3d85abcec..239600f464 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiSimulationValidator.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiSimulationValidator.cs @@ -2,7 +2,6 @@ using UnityEngine.EventSystems; using io.github.hatayama.UnityCliLoop.Runtime; -using io.github.hatayama.UnityCliLoop.ToolContracts; namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { @@ -16,11 +15,11 @@ internal static class MouseUiSimulationValidator EventSystem? eventSystem, string pausedActionDescription) { - ValidationResult playModeResult = + PlayModeToolPreflightResult playModeResult = PlayModeToolPreflightService.RequireActiveAndNotPaused(pausedActionDescription); if (!playModeResult.IsValid) { - return MouseUiSimulationResponseFactory.CreateFailure(parameters, playModeResult.ErrorMessage); + return MouseUiSimulationResponseFactory.CreatePreflightFailure(parameters, playModeResult); } if (eventSystem == null) diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/SimulateMouseUiResponse.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/SimulateMouseUiResponse.cs index 66fd34b283..8da0c6270a 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/SimulateMouseUiResponse.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/SimulateMouseUiResponse.cs @@ -19,6 +19,14 @@ public class SimulateMouseUiResponse : UnityCliLoopToolResponse public float? EndPositionX { get; set; } public float? EndPositionY { get; set; } public bool InterruptedByPausePoint { get; set; } + /// + /// Id of the pause point that refused this call before it ran anything, null otherwise. + /// Distinct from PausePointId, which reports a marker hit *during* the call: a refusal means + /// no input was injected at all, so a caller reading only Success would miss that the action + /// never happened. The CLI's --trigger diagnosis compares this against the marker it awaits. + /// + public string? RejectedByActivePausePointId { get; set; } + public string? PausePointId { get; set; } public int? PausePointHitCount { get; set; } public List? PausePointHits { get; set; } diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/Skill/SKILL.md index b317e85619..0a187f0c03 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/Skill/SKILL.md @@ -28,11 +28,11 @@ uloop simulate-mouse-ui --action --x --y [options] | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | enum | `Click` | `Click`, `Drag`, `DragStart`, `DragMove`, `DragEnd`, `LongPress` | +| `--action` | enum | `Click` | `Click` - click at position, `Drag` - one-shot drag, `DragStart` - begin drag and hold, `DragMove` - move while holding drag, `DragEnd` - release drag, `LongPress` - press and hold for `--duration` seconds | | `--x` | number | `0` | Target X position in screen pixels (origin: top-left). For Drag action, this is the destination. | | `--y` | number | `0` | Target Y position in screen pixels (origin: top-left). For Drag action, this is the destination. | -| `--from-x` | number | `0` | Start X position for Drag action. Drag starts here and moves to x,y. | -| `--from-y` | number | `0` | Start Y position for Drag action. Drag starts here and moves to x,y. | +| `--from-x` | number | `0` | Start X position for Drag action (origin: top-left). Drag starts here and moves to `--x`,`--y`. | +| `--from-y` | number | `0` | Start Y position for Drag action (origin: top-left). Drag starts here and moves to `--x`,`--y`. | | `--drag-speed` | number | `2000` | Drag speed in pixels per second (0 for instant). 2000 is fast (default), 200 is slow enough to watch. Applies to Drag, DragMove, and DragEnd actions. | | `--duration` | number | `0.5` | Hold duration in seconds for LongPress action. | | `--button` | enum | `Left` | Mouse button. `Click` and `LongPress` support `Left`, `Right`, and `Middle`. Drag actions support `Left` only; other buttons return an error. | diff --git a/Packages/src/Editor/ToolContracts/UnityCliLoopToolParameterSchemaGenerator.cs b/Packages/src/Editor/ToolContracts/UnityCliLoopToolParameterSchemaGenerator.cs index 3ffce80653..cc25aefa66 100644 --- a/Packages/src/Editor/ToolContracts/UnityCliLoopToolParameterSchemaGenerator.cs +++ b/Packages/src/Editor/ToolContracts/UnityCliLoopToolParameterSchemaGenerator.cs @@ -132,8 +132,11 @@ private static string GetDescription(PropertyInfo property) if (descAttr != null) return descAttr.Description; - // Generate default description - return $"Parameter: {property.Name}"; + // Generate default description. Built from the JSON property name, not the C# one, + // because the CLI recognizes this placeholder by comparing it against the JSON name it + // received: a [JsonProperty] rename would otherwise make the placeholder unrecognizable + // and leave it in --help output as if it were a real description. + return $"Parameter: {GetJsonPropertyName(property)}"; } /// diff --git a/cli/common/clicore/tool_catalog.go b/cli/common/clicore/tool_catalog.go index 48a825766f..d681745e16 100644 --- a/cli/common/clicore/tool_catalog.go +++ b/cli/common/clicore/tool_catalog.go @@ -29,6 +29,10 @@ func LoadDefaultTools() ToolsCache { return tools.LoadDefault() } +func ApplyEmbeddedDescriptionFallback(cache ToolsCache) ToolsCache { + return tools.ApplyEmbeddedDescriptionFallback(cache) +} + func FindTool(cache ToolsCache, name string) (ToolDefinition, bool) { return tools.Find(cache, name) } diff --git a/cli/common/clicore/tool_readiness.go b/cli/common/clicore/tool_readiness.go index 9686e06973..2dfdbc7ca6 100644 --- a/cli/common/clicore/tool_readiness.go +++ b/cli/common/clicore/tool_readiness.go @@ -61,6 +61,12 @@ func waitForToolReadinessWithDeps(ctx context.Context, projectRoot string, timeo if IsReadinessCLIUpdateRequiredError(err) { return err } + // Why abort: polling cannot outlast a connect the kernel refused permanently, and + // waiting out the timeout replaces that syscall error with server-not-responding + // guidance the caller cannot act on. + if clierrors.IsPermanentConnectError(err) { + return err + } lastErr = err } diff --git a/cli/common/clicore/tool_readiness_test.go b/cli/common/clicore/tool_readiness_test.go index bad93a6557..f078ad5c1a 100644 --- a/cli/common/clicore/tool_readiness_test.go +++ b/cli/common/clicore/tool_readiness_test.go @@ -4,6 +4,9 @@ import ( "context" "encoding/json" "errors" + "net" + "os" + "syscall" "testing" "time" @@ -53,6 +56,43 @@ func TestWaitForToolReadinessReturnsCliUpdateRequiredImmediately(t *testing.T) { } } +// Verifies a connect() the operating system refused permanently ends the readiness wait at the +// first probe and reports that error, instead of polling out the whole timeout and replacing it +// with server-not-responding guidance the caller cannot act on. +func TestWaitForToolReadinessReturnsPermanentlyRefusedConnectImmediately(t *testing.T) { + expectedErr := &unityipc.ConnectionAttemptError{ + Endpoint: "/tmp/uloop-501/UnityCliLoop-sample.sock", + Cause: &net.OpError{ + Op: "dial", + Net: "unix", + Addr: &net.UnixAddr{Name: "/tmp/uloop-501/UnityCliLoop-sample.sock", Net: "unix"}, + Err: os.NewSyscallError("connect", syscall.EPERM), + }, + } + probeCount := 0 + deps := toolReadinessDeps{ + probeToolReadinessSequence: func(context.Context, string) error { + probeCount++ + return expectedErr + }, + findRunningUnityProcess: func(context.Context, string) (*unityprocess.UnityProcess, error) { + return nil, nil + }, + } + + // Why a timeout shorter than the poll interval: without the abort the wait falls through to + // its own timeout, and this test then fails on the assertions below rather than hanging until + // the package test deadline. + err := waitForToolReadinessWithDeps(context.Background(), t.TempDir(), ToolReadinessPoll/10, deps) + + if err != error(expectedErr) { + t.Fatalf("expected the refused connect error itself, got %v", err) + } + if probeCount != 1 { + t.Fatalf("expected the wait to stop after the first probe, got %d probes", probeCount) + } +} + // Verifies that parent cancellation is preserved instead of being reported as a timeout. func TestToolReadinessDoneErrorPropagatesParentCancellation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) diff --git a/cli/common/errors/error_envelope.go b/cli/common/errors/error_envelope.go index 3e1a947f80..986187a2ad 100644 --- a/cli/common/errors/error_envelope.go +++ b/cli/common/errors/error_envelope.go @@ -11,7 +11,7 @@ import ( const ( ErrorCodeInvalidArgument = "INVALID_ARGUMENT" - errorCodeUnknownCommand = "UNKNOWN_COMMAND" + ErrorCodeUnknownCommand = "UNKNOWN_COMMAND" errorCodeProjectNotFound = "PROJECT_NOT_FOUND" ErrorCodeUnityNotReachable = "UNITY_NOT_REACHABLE" ErrorCodeUnityStartupTimeout = "UNITY_STARTUP_TIMEOUT" @@ -33,6 +33,7 @@ const ( ErrorCodePausePointWaitTimeout = "PAUSE_POINT_WAIT_TIMEOUT" ErrorCodePausePointExpired = "PAUSE_POINT_EXPIRED" ErrorCodePausePointCleared = "PAUSE_POINT_CLEARED" + ErrorCodePausePointTriggerFailed = "PAUSE_POINT_TRIGGER_FAILED" ErrorCodeInternalError = "INTERNAL_ERROR" ErrorPhaseArgumentParsing = "argument_parsing" @@ -259,7 +260,7 @@ func unityServerNotRespondingAfterDispatchError(err UnityServerNotRespondingErro func UnknownCommandError(command string, availableCommands []string, context ErrorContext) CLIError { return CLIError{ - ErrorCode: errorCodeUnknownCommand, + ErrorCode: ErrorCodeUnknownCommand, Phase: ErrorPhaseDispatch, Message: "Unknown command: " + command, Retryable: false, diff --git a/cli/common/errors/error_envelope_classification.go b/cli/common/errors/error_envelope_classification.go index 9942c37841..c3ecd2f107 100644 --- a/cli/common/errors/error_envelope_classification.go +++ b/cli/common/errors/error_envelope_classification.go @@ -89,6 +89,9 @@ func unityServerNotRespondingCLIError(err UnityServerNotRespondingError, context } func connectionAttemptCLIError(err *unityipc.ConnectionAttemptError, context ErrorContext) CLIError { + if IsPermanentConnectError(err) { + return refusedConnectionAttemptCLIError(err, context) + } return CLIError{ ErrorCode: ErrorCodeUnityNotReachable, Phase: ErrorPhaseConnection, @@ -109,6 +112,30 @@ func connectionAttemptCLIError(err *unityipc.ConnectionAttemptError, context Err } } +// Reports a connect the operating system refused permanently. Waiting changes nothing here, so +// the envelope states the syscall error as it came back and points at what actually blocks the +// socket instead of repeating the reachability guidance. +func refusedConnectionAttemptCLIError(err *unityipc.ConnectionAttemptError, context ErrorContext) CLIError { + return CLIError{ + ErrorCode: ErrorCodeUnityNotReachable, + Phase: ErrorPhaseConnection, + Message: "The operating system refused the connection to the Unity CLI Loop server for this project.", + Retryable: false, + SafeToRetry: false, + ProjectRoot: firstNonEmpty(context.ProjectRoot, err.ProjectRoot), + Command: context.Command, + NextActions: []string{ + "Retrying will not help: the connection was refused before it reached Unity, and the Editor never saw it.", + "If this command ran inside a sandbox (for example an AI agent's sandboxed shell), the sandbox denied the connection; run it with sandboxing disabled for this command.", + "Otherwise check the ownership and permissions of the endpoint path in Details.", + }, + Details: map[string]any{ + "Endpoint": err.Endpoint, + "Cause": connectionAttemptCause(err), + }, + } +} + func classifyRPCError(rpcErr *unityipc.RPCError, context ErrorContext) CLIError { details, decodedData := rpcErrorDetails(rpcErr) switch RPCDataType(decodedData) { diff --git a/cli/common/errors/error_envelope_test.go b/cli/common/errors/error_envelope_test.go index 438f04798b..f46f75172f 100644 --- a/cli/common/errors/error_envelope_test.go +++ b/cli/common/errors/error_envelope_test.go @@ -4,7 +4,10 @@ import ( "bytes" "encoding/json" "errors" + "net" + "os" "strings" + "syscall" "testing" "github.com/hatayama/unity-cli-loop/common/unityipc" @@ -65,6 +68,35 @@ func TestClassifyConnectionAttemptError(t *testing.T) { } } +// Verifies a connect() the operating system refused outright is reported as a permanent +// failure carrying the syscall text verbatim: the retry guidance sent the agent into a +// 60-second wait for a condition (sandbox policy, socket permissions) that never clears. +func TestClassifyConnectionAttemptErrorForRefusedConnect(t *testing.T) { + err := &unityipc.ConnectionAttemptError{ + ProjectRoot: "/tmp/MyProject", + Endpoint: "/tmp/uloop-501/UnityCliLoop-sample.sock", + Cause: &net.OpError{ + Op: "dial", + Net: "unix", + Addr: &net.UnixAddr{Name: "/tmp/uloop-501/UnityCliLoop-sample.sock", Net: "unix"}, + Err: os.NewSyscallError("connect", syscall.EPERM), + }, + } + + cliErr := ClassifyError(err, ErrorContext{Command: "compile"}) + if cliErr.Retryable || cliErr.SafeToRetry { + t.Fatalf("a permanently refused connect must not be advertised as retryable: %#v", cliErr) + } + if cliErr.Details["Cause"] != err.Cause.Error() { + t.Fatalf("the syscall error must be reported verbatim: %#v", cliErr.Details) + } + for _, action := range cliErr.NextActions { + if strings.Contains(strings.ToLower(action), "wait and retry") { + t.Fatalf("next actions must not advise waiting: %#v", cliErr.NextActions) + } + } +} + func TestClassifyConnectionAttemptAllowsNilCause(t *testing.T) { // Verifies connection classification handles a missing low-level cause. err := &unityipc.ConnectionAttemptError{ @@ -444,7 +476,7 @@ func TestUnknownCommandErrorIncludesAvailableCommands(t *testing.T) { ErrorContext{ProjectRoot: "/tmp/MyProject"}, ) - if cliErr.ErrorCode != errorCodeUnknownCommand { + if cliErr.ErrorCode != ErrorCodeUnknownCommand { t.Fatalf("error code mismatch: %#v", cliErr) } available, ok := cliErr.Details["AvailableCommands"].([]string) diff --git a/cli/common/errors/transport_errors.go b/cli/common/errors/transport_errors.go index ac9955a3cc..a0abd491f9 100644 --- a/cli/common/errors/transport_errors.go +++ b/cli/common/errors/transport_errors.go @@ -42,6 +42,24 @@ func IsTransportDisconnectError(err error) bool { strings.Contains(message, "use of closed network connection") } +// Reports whether a dial failed for a reason that cannot clear while the caller waits: the +// kernel refused the socket outright (a sandbox policy that denies Unix socket connects, +// permissions on the socket path) instead of reporting that nobody is listening yet. Retrying +// such an error wastes the whole dial-retry window and then reports the window's own deadline +// expiry, so the syscall error the first attempt already had never reaches the caller. +// Why os.ErrPermission rather than the POSIX errnos: a Windows named pipe reports access denial +// as ERROR_ACCESS_DENIED, which maps to os.ErrPermission but matches neither EPERM nor EACCES, so +// an errno test would leave Windows retrying a refusal that never clears. Why only inside a +// connection attempt: os.ErrPermission also covers file permission failures that reach the same +// callers (project resolution, endpoint inspection), and those are not dial outcomes. +func IsPermanentConnectError(err error) bool { + var connectionErr *unityipc.ConnectionAttemptError + if !errors.As(err, &connectionErr) { + return false + } + return errors.Is(connectionErr, os.ErrPermission) +} + // Reports whether the error is a connection deadline expiry. The Timeout() probe // runs through the unwrap chain because go-winio's named pipe deadline error is not // os.ErrDeadlineExceeded and os.IsTimeout does not unwrap fmt.Errorf("%w") wrapping. diff --git a/cli/common/errors/transport_errors_test.go b/cli/common/errors/transport_errors_test.go index 00025b082c..bc6c8627a5 100644 --- a/cli/common/errors/transport_errors_test.go +++ b/cli/common/errors/transport_errors_test.go @@ -94,6 +94,74 @@ func TestIsTransportDisconnectErrorMatchesWrappedNoResponseError(t *testing.T) { } } +// Verifies a connect() refused by the operating system is classified as permanent, so the +// dial-retry loops abort instead of spending their window on an error that cannot clear. +func TestIsPermanentConnectErrorMatchesRefusedSyscalls(t *testing.T) { + refusedSyscalls := []syscall.Errno{syscall.EPERM, syscall.EACCES} + + for _, errno := range refusedSyscalls { + dialError := &net.OpError{ + Op: "dial", + Net: "unix", + Addr: &net.UnixAddr{Name: "/tmp/uloop-501/UnityCliLoop-sample.sock", Net: "unix"}, + Err: os.NewSyscallError("connect", errno), + } + wrapped := &unityipc.ConnectionAttemptError{Cause: dialError} + if !IsPermanentConnectError(wrapped) { + t.Fatalf("refused connect was not classified as permanent: %v", wrapped) + } + } +} + +// Verifies a named pipe access denial is classified as permanent too. go-winio reports it as a +// path error whose cause maps to os.ErrPermission and to neither POSIX errno, so matching errnos +// alone would leave Windows retrying a refusal that never clears. +func TestIsPermanentConnectErrorMatchesNamedPipeAccessDenial(t *testing.T) { + deniedPipe := &unityipc.ConnectionAttemptError{ + Cause: &os.PathError{ + Op: "open", + Path: `\\.\pipe\UnityCliLoop-sample`, + Err: os.ErrPermission, + }, + } + + if !IsPermanentConnectError(deniedPipe) { + t.Fatalf("denied named pipe was not classified as permanent: %v", deniedPipe) + } +} + +// Verifies a permission failure that is not a dial outcome stays out of this classification: the +// same callers also surface project and endpoint file errors, and those must not abort a wait. +func TestIsPermanentConnectErrorIgnoresPermissionErrorsOutsideDialing(t *testing.T) { + fileError := &os.PathError{ + Op: "open", + Path: "/tmp/MyProject/ProjectSettings/ProjectVersion.txt", + Err: syscall.EACCES, + } + + if IsPermanentConnectError(fileError) { + t.Fatalf("a file permission error was classified as a refused connect: %v", fileError) + } +} + +// Verifies the errors a retry is meant to absorb — the socket not existing yet, nobody +// listening yet, a deadline expiry — stay retryable. +func TestIsPermanentConnectErrorRejectsTransientFailures(t *testing.T) { + transientErrors := []error{ + nil, + &unityipc.ConnectionAttemptError{Cause: os.NewSyscallError("connect", syscall.ENOENT)}, + &unityipc.ConnectionAttemptError{Cause: os.NewSyscallError("connect", syscall.ECONNREFUSED)}, + &unityipc.ConnectionAttemptError{Cause: timeoutOnlyError{}}, + fmt.Errorf("dial unix /tmp/uloop-501/UnityCliLoop-sample.sock: i/o timeout"), + } + + for _, err := range transientErrors { + if IsPermanentConnectError(err) { + t.Fatalf("transient error was classified as permanent: %v", err) + } + } +} + // Verifies that final-response timeout classification matches typed deadline errors // and Timeout()-reporting errors instead of relying on the "i/o timeout" message. func TestIsFinalResponseTimeoutErrorMatchesTypedCauses(t *testing.T) { diff --git a/cli/common/errors/transport_errors_windows_test.go b/cli/common/errors/transport_errors_windows_test.go new file mode 100644 index 0000000000..a7628f0124 --- /dev/null +++ b/cli/common/errors/transport_errors_windows_test.go @@ -0,0 +1,33 @@ +//go:build windows + +package clierrors + +import ( + "os" + "syscall" + "testing" + + "github.com/hatayama/unity-cli-loop/common/unityipc" +) + +// The status Windows returns when opening the project's named pipe is denied, and the one +// go-winio puts in the path error it hands back. +const windowsErrorAccessDenied = syscall.Errno(5) + +// Verifies the classification holds against the real Windows status code. The cross-platform test +// substitutes os.ErrPermission for it, which assumes the mapping Go's syscall package performs; +// this test fails if that assumption ever stops holding and Windows silently starts retrying a +// refusal that never clears. +func TestIsPermanentConnectErrorMatchesWindowsAccessDenied(t *testing.T) { + deniedPipe := &unityipc.ConnectionAttemptError{ + Cause: &os.PathError{ + Op: "open", + Path: `\\.\pipe\UnityCliLoop-sample`, + Err: windowsErrorAccessDenied, + }, + } + + if !IsPermanentConnectError(deniedPipe) { + t.Fatalf("denied named pipe was not classified as permanent: %v", deniedPipe) + } +} diff --git a/cli/common/skilldocs/apply.go b/cli/common/skilldocs/apply.go new file mode 100644 index 0000000000..f7d8297cbe --- /dev/null +++ b/cli/common/skilldocs/apply.go @@ -0,0 +1,57 @@ +package skilldocs + +import ( + "github.com/hatayama/unity-cli-loop/common/tooldocs" + "github.com/hatayama/unity-cli-loop/common/tools" +) + +// ApplyToCatalog overlays the installed package's skill documentation onto a tool catalog. The skill +// wins over every other source: it is the text a human reviewed and an agent reads, while the schema +// carries generated placeholders and the embedded catalog is a snapshot of an older generation. +// Tools with no skill (project-local custom commands) pass through untouched. +func ApplyToCatalog(catalog tools.ToolCatalog, projectRoot string) tools.ToolCatalog { + docs := Load(projectRoot) + if len(docs) == 0 { + return catalog + } + + for index, tool := range catalog.Tools { + catalog.Tools[index] = applyToolDocs(tool, docs) + } + return catalog +} + +// ApplyToTool is ApplyToCatalog for the single-command help path. +func ApplyToTool(tool tools.ToolDefinition, projectRoot string) tools.ToolDefinition { + docs := Load(projectRoot) + if len(docs) == 0 { + return tool + } + return applyToolDocs(tool, docs) +} + +func applyToolDocs(tool tools.ToolDefinition, docs map[string]ToolDocs) tools.ToolDefinition { + toolDocs, ok := docs[tool.Name] + if !ok { + return tool + } + + if toolDocs.ToolDescription != "" { + tool.Description = toolDocs.ToolDescription + } + + // Properties are matched by their CLI option name, produced by the one kebab-conversion in the + // codebase. Writing the inverse conversion here would be a second rule to keep in step. + schema := tool.EffectiveInputSchema() + for propertyName, property := range schema.Properties { + optionName := tooldocs.OptionNameForProperty(tool.Name, propertyName, property) + description, ok := toolDocs.ParamDescriptions[optionName] + if !ok || description == "" { + continue + } + property.Description = description + property.SkillSourcedDescription = true + schema.Properties[propertyName] = property + } + return tool +} diff --git a/cli/common/skilldocs/apply_test.go b/cli/common/skilldocs/apply_test.go new file mode 100644 index 0000000000..93e9e33cd0 --- /dev/null +++ b/cli/common/skilldocs/apply_test.go @@ -0,0 +1,153 @@ +package skilldocs + +import ( + "os" + "path/filepath" + "testing" + + "github.com/hatayama/unity-cli-loop/common/tools" +) + +// writeFixtureProject builds a Unity project whose Packages/src holds the uloop package, which is +// the layout ResolvePackageRoot recognizes for a locally embedded package. +func writeFixtureProject(t *testing.T, skills map[string]string) string { + t.Helper() + + projectRoot := t.TempDir() + packageRoot := filepath.Join(projectRoot, "Packages", "src") + if err := os.MkdirAll(packageRoot, 0o755); err != nil { + t.Fatalf("failed to create the package root: %v", err) + } + manifest := []byte(`{"name":"io.github.hatayama.uloopmcp"}`) + if err := os.WriteFile(filepath.Join(packageRoot, "package.json"), manifest, 0o644); err != nil { + t.Fatalf("failed to write the package manifest: %v", err) + } + // Editor/FirstPartyTools is how ResolvePackageRoot recognizes a candidate as the uloop package, + // so it exists in every install regardless of which skills this fixture writes. + if err := os.MkdirAll(filepath.Join(packageRoot, "Editor", "FirstPartyTools"), 0o755); err != nil { + t.Fatalf("failed to create the first-party tools directory: %v", err) + } + + for relativeDirectory, content := range skills { + skillDirectory := filepath.Join(packageRoot, "Editor", relativeDirectory, "Skill") + if err := os.MkdirAll(skillDirectory, 0o755); err != nil { + t.Fatalf("failed to create %s: %v", skillDirectory, err) + } + if err := os.WriteFile(filepath.Join(skillDirectory, "SKILL.md"), []byte(content), 0o644); err != nil { + t.Fatalf("failed to write %s: %v", skillDirectory, err) + } + } + return projectRoot +} + +func fixtureCatalog() tools.ToolCatalog { + return tools.ToolCatalog{Tools: []tools.ToolDefinition{ + { + Name: "simulate-keyboard", + Description: "Stale catalog description.", + InputSchema: tools.ToolInputSchema{ + Type: "object", + Properties: map[string]tools.ToolProperty{ + "Action": {Type: "string", Description: "Parameter: Action"}, + "Duration": {Type: "number", Description: "Stale duration description."}, + }, + }, + }, + { + Name: "my-custom-command", + Description: "A project-local command with no skill.", + InputSchema: tools.ToolInputSchema{ + Type: "object", + Properties: map[string]tools.ToolProperty{"Amount": {Type: "number", Description: "Author's own text."}}, + }, + }, + }} +} + +// Verifies the skill table replaces both the placeholder and the non-placeholder descriptions a +// catalog carries, which is the drift this renderer exists to remove. +func TestApplyToCatalogPrefersTheSkillTable(t *testing.T) { + projectRoot := writeFixtureProject(t, map[string]string{"FirstPartyTools/SimulateKeyboard": singleToolSkill}) + + catalog := ApplyToCatalog(fixtureCatalog(), projectRoot) + + tool, ok := tools.Find(catalog, "simulate-keyboard") + if !ok { + t.Fatal("simulate-keyboard is missing from the catalog") + } + if tool.Description != "Simulate keyboard input in PlayMode." { + t.Errorf("tool description was not taken from the skill: %q", tool.Description) + } + properties := tool.EffectiveInputSchema().Properties + if got := properties["Action"].Description; got != "Press | KeyDown | KeyUp" { + t.Errorf("Action description was not taken from the skill: %q", got) + } + if got := properties["Duration"].Description; got != "Hold duration in seconds." { + t.Errorf("a real-looking description must still lose to the skill: %q", got) + } +} + +// Verifies a command the package documents nowhere keeps the description its own author wrote, so +// custom commands are unaffected by this layer. +func TestApplyToCatalogLeavesUndocumentedCommandsAlone(t *testing.T) { + projectRoot := writeFixtureProject(t, map[string]string{"FirstPartyTools/SimulateKeyboard": singleToolSkill}) + + catalog := ApplyToCatalog(fixtureCatalog(), projectRoot) + + tool, ok := tools.Find(catalog, "my-custom-command") + if !ok { + t.Fatal("my-custom-command is missing from the catalog") + } + if tool.Description != "A project-local command with no skill." { + t.Errorf("tool description changed: %q", tool.Description) + } + if got := tool.EffectiveInputSchema().Properties["Amount"].Description; got != "Author's own text." { + t.Errorf("property description changed: %q", got) + } +} + +// Verifies a project with no uloop package installed keeps the catalog exactly as it was: a missing +// skill must degrade the text, never the command. +func TestApplyToCatalogFallsBackWhenNoPackageIsInstalled(t *testing.T) { + catalog := ApplyToCatalog(fixtureCatalog(), t.TempDir()) + + tool, _ := tools.Find(catalog, "simulate-keyboard") + if tool.Description != "Stale catalog description." { + t.Errorf("tool description changed without a package: %q", tool.Description) + } + if got := tool.EffectiveInputSchema().Properties["Duration"].Description; got != "Stale duration description." { + t.Errorf("property description changed without a package: %q", got) + } +} + +// Verifies an empty project root is a no-op, the shape taken by help resolved outside any project. +func TestApplyToToolWithoutAProjectRootIsANoOp(t *testing.T) { + tool := ApplyToTool(fixtureCatalog().Tools[0], "") + + if tool.Description != "Stale catalog description." { + t.Errorf("tool description changed with no project root: %q", tool.Description) + } +} + +// Verifies the skills of the CLI-only commands are read too, so the pause-point family - documented +// by a single multi-command skill in CliOnlyTools~ - is covered. +func TestApplyToToolReadsCliOnlySkills(t *testing.T) { + projectRoot := writeFixtureProject(t, map[string]string{"CliOnlyTools~/PausePoint": multiToolSkill}) + + tool := ApplyToTool(tools.ToolDefinition{ + Name: "enable-pause-point", + Description: "Stale.", + InputSchema: tools.ToolInputSchema{ + Type: "object", + Properties: map[string]tools.ToolProperty{"MaxHistory": {Type: "integer", Description: "Parameter: MaxHistory"}}, + }, + }, projectRoot) + + if tool.Description != "Enable a pause point so Unity pauses when that code path is reached" { + t.Errorf("tool description was not taken from the skill subsection: %q", tool.Description) + } + expected := "Maximum number of captured hit frames to retain (1-100)" + if got := tool.EffectiveInputSchema().Properties["MaxHistory"].Description; got != expected { + t.Errorf("MaxHistory description: %q", got) + } +} diff --git a/cli/common/skilldocs/discover.go b/cli/common/skilldocs/discover.go new file mode 100644 index 0000000000..572b7382ab --- /dev/null +++ b/cli/common/skilldocs/discover.go @@ -0,0 +1,101 @@ +package skilldocs + +import ( + "os" + "path/filepath" + "sort" + + "github.com/hatayama/unity-cli-loop/common/skillscan" + "github.com/hatayama/unity-cli-loop/common/vibelog" +) + +const ( + editorDirectoryName = "Editor" + firstPartyToolsDirName = "FirstPartyTools" + cliOnlyToolsDirName = "CliOnlyTools~" + skillDirectoryName = "Skill" + + skillDocsLogOperation = "skill_docs_render" +) + +// Load reads every skill in the installed uloop package and returns what each one documents, keyed +// by tool name. A project without the package, an unreadable file, or a skill that documents nothing +// yields no entry for the affected tools; callers then fall back to the descriptions they already +// had. Help that prints stale text is a nuisance, help that fails to print is a broken CLI. +func Load(projectRoot string) map[string]ToolDocs { + if projectRoot == "" { + return nil + } + + packageRoot := skillscan.ResolvePackageRoot(projectRoot) + if packageRoot == "" { + logSkillDocsFallback(projectRoot, "uloop package root not found; keeping embedded descriptions", nil) + return nil + } + + result := map[string]ToolDocs{} + for _, skillPath := range skillFilePaths(packageRoot) { + content, err := os.ReadFile(skillPath) + if err != nil { + logSkillDocsFallback(projectRoot, "skill file could not be read", map[string]any{ + "skill_path": skillPath, + "error": err.Error(), + }) + continue + } + + parsed := ParseSkill(string(content)) + if len(parsed) == 0 { + logSkillDocsFallback(projectRoot, "skill file documented no tool", map[string]any{ + "skill_path": skillPath, + }) + continue + } + for toolName, docs := range parsed { + result[toolName] = docs + } + } + return result +} + +// skillFilePaths lists the skill sources shipped inside the package. Both containers are read +// wholesale rather than by a hard-coded tool list, so a new tool's skill is picked up by adding the +// folder alone. CliOnlyTools~ holds the skills for commands with no Unity tool class; the ones that +// name no live tool simply never match a catalog entry. +func skillFilePaths(packageRoot string) []string { + paths := []string{} + for _, containerName := range []string{firstPartyToolsDirName, cliOnlyToolsDirName} { + containerPath := filepath.Join(packageRoot, editorDirectoryName, containerName) + entries, err := os.ReadDir(containerPath) + if err != nil { + continue + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + skillPath := filepath.Join(containerPath, entry.Name(), skillDirectoryName, skillscan.SkillFileName) + if _, err := os.Stat(skillPath); err != nil { + continue + } + paths = append(paths, skillPath) + } + } + sort.Strings(paths) + return paths +} + +// logSkillDocsFallback records why a layer was skipped. The fallback is deliberately silent on +// stdout - a diagnostic line would corrupt `uloop list` output - so this log is the only trace. +func logSkillDocsFallback(projectRoot string, message string, context map[string]any) { + if !vibelog.IsCLIVibeLogEnabled() { + return + } + _ = vibelog.WriteCLIVibeLog(projectRoot, vibelog.CLIVibeLogEntry{ + Level: "WARNING", + Operation: skillDocsLogOperation, + Message: message, + Context: context, + HumanNote: "Help and list fell back to the descriptions compiled into this binary.", + }) +} diff --git a/cli/common/skilldocs/parse.go b/cli/common/skilldocs/parse.go new file mode 100644 index 0000000000..c155caa2ba --- /dev/null +++ b/cli/common/skilldocs/parse.go @@ -0,0 +1,279 @@ +package skilldocs + +import ( + "strings" + + "github.com/hatayama/unity-cli-loop/common/skillscan" +) + +const ( + parametersSectionHeading = "## Parameters" + skillNamePrefix = "uloop-" + + sectionHeadingPrefix = "## " + subsectionHeadingPrefix = "### " + byteOrderMark = "\uFEFF" +) + +// standardParameterTableCells is the header every parameter table in this repository uses. Tables +// with any other header (action matrices, comparison tables) are prose and must not be read as +// parameter documentation. +var standardParameterTableCells = []string{"Parameter", "Type", "Default", "Description"} + +// ParseSkill reads one SKILL.md body. Two layouts exist and both are supported: +// +// (i) one skill per tool - frontmatter toolName/description plus a single parameter table +// anywhere in the file (first-party tool skills put it under "### Parameters"). +// (ii) one skill covering several commands (pause-point) - a "## Parameters" section whose +// "### " subsections each carry a description line and their own table. +// +// A file that documents no parameters still yields its tool description, which is why the result is +// keyed by tool name rather than returned only when a table was found. +func ParseSkill(content string) map[string]ToolDocs { + lines := normalizedLines(content) + sectionLines, ok := parametersSectionLines(lines) + if ok && hasSubsectionHeading(sectionLines) { + return parseMultiToolSkill(sectionLines) + } + return parseSingleToolSkill(lines) +} + +// normalizedLines makes the parser indifferent to how the checkout wrote the file. A Windows +// checkout produces CRLF and some editors prepend a BOM; neither may change what help prints. +func normalizedLines(content string) []string { + content = strings.TrimPrefix(content, byteOrderMark) + content = strings.ReplaceAll(content, "\r\n", "\n") + content = strings.ReplaceAll(content, "\r", "\n") + return strings.Split(content, "\n") +} + +// parseSingleToolSkill reads the frontmatter from the same normalized lines the table is read from, so +// line endings and a BOM are handled in exactly one place rather than once per consumer. +func parseSingleToolSkill(lines []string) map[string]ToolDocs { + frontmatter := skillscan.ParseSkillFrontmatter(strings.Join(lines, "\n")) + toolName := singleSkillToolName(frontmatter) + if toolName == "" { + return map[string]ToolDocs{} + } + + docs := ToolDocs{ + ToolDescription: frontmatter["description"], + ParamDescriptions: map[string]string{}, + } + if headerIndex, ok := findParameterTableHeader(lines, 0); ok { + docs.ParamDescriptions = parseParameterTable(lines, headerIndex) + } + return map[string]ToolDocs{toolName: docs} +} + +// singleSkillToolName resolves the tool a one-tool skill documents. toolName is authoritative when +// present; otherwise the skill name carries it, since every skill in this package is named +// "uloop-" (focus-window's skill declares no toolName). +func singleSkillToolName(frontmatter map[string]string) string { + if toolName := frontmatter["toolName"]; toolName != "" { + return toolName + } + name := frontmatter["name"] + if !strings.HasPrefix(name, skillNamePrefix) { + return "" + } + return strings.TrimPrefix(name, skillNamePrefix) +} + +func parseMultiToolSkill(sectionLines []string) map[string]ToolDocs { + result := map[string]ToolDocs{} + for index := 0; index < len(sectionLines); index++ { + line := strings.TrimSpace(sectionLines[index]) + if !strings.HasPrefix(line, subsectionHeadingPrefix) { + continue + } + + toolName := strings.TrimSpace(strings.TrimPrefix(line, subsectionHeadingPrefix)) + if toolName == "" { + continue + } + blockLines := subsectionLines(sectionLines, index) + docs := ToolDocs{ + ToolDescription: firstProseLine(blockLines), + ParamDescriptions: map[string]string{}, + } + if headerIndex, ok := findParameterTableHeader(blockLines, 0); ok { + docs.ParamDescriptions = parseParameterTable(blockLines, headerIndex) + } + result[toolName] = docs + } + return result +} + +// parametersSectionLines returns the body of the "## Parameters" section, which is where a +// multi-tool skill keeps its per-command subsections. +func parametersSectionLines(lines []string) ([]string, bool) { + for index, line := range lines { + if strings.TrimSpace(line) != parametersSectionHeading { + continue + } + for end := index + 1; end < len(lines); end++ { + if strings.HasPrefix(strings.TrimSpace(lines[end]), sectionHeadingPrefix) { + return lines[index+1 : end], true + } + } + return lines[index+1:], true + } + return nil, false +} + +func hasSubsectionHeading(lines []string) bool { + for _, line := range lines { + if strings.HasPrefix(strings.TrimSpace(line), subsectionHeadingPrefix) { + return true + } + } + return false +} + +func subsectionLines(sectionLines []string, headingIndex int) []string { + for end := headingIndex + 1; end < len(sectionLines); end++ { + if strings.HasPrefix(strings.TrimSpace(sectionLines[end]), subsectionHeadingPrefix) { + return sectionLines[headingIndex+1 : end] + } + } + return sectionLines[headingIndex+1:] +} + +// firstProseLine is the tool description in a multi-tool skill: the first non-empty line under the +// tool's heading that is not part of a table. +func firstProseLine(lines []string) string { + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "|") { + continue + } + return trimmed + } + return "" +} + +func findParameterTableHeader(lines []string, startIndex int) (int, bool) { + for index := startIndex; index < len(lines); index++ { + if isStandardParameterTableHeader(lines[index]) { + return index, true + } + } + return 0, false +} + +func isStandardParameterTableHeader(line string) bool { + if !strings.HasPrefix(strings.TrimSpace(line), "|") { + return false + } + cells := splitTableRow(line) + if len(cells) != len(standardParameterTableCells) { + return false + } + for index, expected := range standardParameterTableCells { + if cells[index] != expected { + return false + } + } + return true +} + +// parseParameterTable reads the rows under a standard header into option name -> description. +func parseParameterTable(lines []string, headerIndex int) map[string]string { + descriptions := map[string]string{} + index := headerIndex + 1 + // The separator row (|---|---|) carries no data; a table without one is malformed, and reading + // it as a row would register a parameter named "---". + if index < len(lines) && isTableSeparatorRow(lines[index]) { + index++ + } + + for ; index < len(lines); index++ { + if !strings.HasPrefix(strings.TrimSpace(lines[index]), "|") { + break + } + cells := splitTableRow(lines[index]) + if len(cells) < len(standardParameterTableCells) { + continue + } + optionName := optionNameFromCell(cells[0]) + description := NormalizeCellText(cells[len(standardParameterTableCells)-1]) + if optionName == "" || description == "" { + continue + } + descriptions[optionName] = description + } + return descriptions +} + +func isTableSeparatorRow(line string) bool { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "|") { + return false + } + return strings.Trim(trimmed, "|-: \t") == "" +} + +// optionNameFromCell turns a first-column cell such as "`--max-history`" into "max-history", the +// form tooldocs.OptionNameForProperty produces for a schema property. +func optionNameFromCell(cell string) string { + name := NormalizeCellText(cell) + if fields := strings.Fields(name); len(fields) > 0 { + name = fields[0] + } + return strings.TrimPrefix(name, "--") +} + +// NormalizeCellText turns one raw table cell into the plain text CLI surfaces print. It is the only +// Markdown-to-text step in this package, and every consumer of a skill table - this renderer, the +// catalog generator, and the CI drift check - must run cells through it so all three compare and +// emit exactly the same string. +// +// Two conversions happen, and deliberately no more: +// - "\|" becomes "|", the escape a table needs for enum alternations such as "Press|KeyDown". +// - code-span backticks are dropped, because "`Press`, `KeyDown`" reads as noise in terminal help +// and in list JSON alike. +// +// No other Markdown is interpreted. If bold or a link ever appears in a cell it shows up verbatim in +// help, which is a visible signal to fix the table rather than a reason to grow a Markdown renderer. +func NormalizeCellText(cell string) string { + text := strings.ReplaceAll(cell, `\|`, "|") + return strings.TrimSpace(strings.ReplaceAll(text, "`", "")) +} + +// splitTableRow splits a Markdown table row on unescaped pipes, leaving each cell's text otherwise +// untouched for NormalizeCellText to convert. Descriptions legitimately contain "|" (enum +// alternations such as "Press|KeyDown"), which the table escapes as "\|"; splitting naively would +// truncate those cells and shift every later column. +func splitTableRow(line string) []string { + trimmed := strings.TrimSpace(line) + trimmed = strings.TrimPrefix(trimmed, "|") + trimmed = strings.TrimSuffix(trimmed, "|") + + cells := []string{} + current := strings.Builder{} + escaped := false + for _, char := range trimmed { + if escaped { + // The escape sequence is kept intact; only NormalizeCellText resolves it, so the split + // stays a purely structural step. + current.WriteRune('\\') + current.WriteRune(char) + escaped = false + continue + } + switch char { + case '\\': + escaped = true + case '|': + cells = append(cells, strings.TrimSpace(current.String())) + current.Reset() + default: + current.WriteRune(char) + } + } + if escaped { + current.WriteRune('\\') + } + return append(cells, strings.TrimSpace(current.String())) +} diff --git a/cli/common/skilldocs/parse_test.go b/cli/common/skilldocs/parse_test.go new file mode 100644 index 0000000000..5435f230e0 --- /dev/null +++ b/cli/common/skilldocs/parse_test.go @@ -0,0 +1,207 @@ +package skilldocs + +import ( + "strings" + "testing" +) + +const singleToolSkill = `--- +name: uloop-simulate-keyboard +toolName: simulate-keyboard +description: "Simulate keyboard input in PlayMode." +--- + +# Task + +## Actions + +| Action | Behavior | Use Case | +|--------|----------|----------| +| ` + "`Press`" + ` | KeyDown then KeyUp | One-shot tap | + +## Tool Reference + +### Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| ` + "`--action`" + ` | enum | ` + "`Press`" + ` | Press \| KeyDown \| KeyUp | +| ` + "`--duration`" + ` | number | ` + "`0`" + ` | Hold duration in seconds. | +| ` + "`--ignored`" + ` | string | - | | + +## Notes + +Prose after the table. +` + +const multiToolSkill = `--- +name: uloop-pause-point +description: "Pauses Unity playback at any source file:line." +--- + +# uloop await-pause-point + +## Parameters + +CLI-only flags are described in the sections above. + +### enable-pause-point + +Enable a pause point so Unity pauses when that code path is reached + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| ` + "`--id`" + ` | string | - | Named pause point id | +| ` + "`--max-history`" + ` | integer | ` + "`20`" + ` | Maximum number of captured hit frames to retain (1-100) | + +### clear-watch + +Clear one or all registered C# watch expressions + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| ` + "`--all`" + ` | flag | - | Clear every registered watch expression | + +## Capture Modes and History + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| ` + "`--not-a-parameter`" + ` | string | - | This table is outside the Parameters section | +` + +// Verifies a one-tool skill yields its frontmatter description plus the rows of its parameter table, +// and that tables with other headers are not read as parameter documentation. +func TestParseSkillReadsASingleToolSkill(t *testing.T) { + docs := ParseSkill(singleToolSkill) + + if len(docs) != 1 { + t.Fatalf("expected exactly one documented tool, got %v", docs) + } + toolDocs, ok := docs["simulate-keyboard"] + if !ok { + t.Fatalf("simulate-keyboard is missing: %v", docs) + } + if toolDocs.ToolDescription != "Simulate keyboard input in PlayMode." { + t.Errorf("tool description not taken from frontmatter: %q", toolDocs.ToolDescription) + } + if got := toolDocs.ParamDescriptions["duration"]; got != "Hold duration in seconds." { + t.Errorf("--duration description: %q", got) + } + if _, ok := toolDocs.ParamDescriptions["Press"]; ok { + t.Error("the Actions table must not be read as parameter documentation") + } + if _, ok := toolDocs.ParamDescriptions["ignored"]; ok { + t.Error("an empty description cell must not register a parameter") + } +} + +// Verifies an escaped pipe inside a description survives as a literal pipe instead of truncating the +// cell and shifting every later column. +func TestParseSkillUnescapesPipesInDescriptions(t *testing.T) { + docs := ParseSkill(singleToolSkill) + + if got := docs["simulate-keyboard"].ParamDescriptions["action"]; got != "Press | KeyDown | KeyUp" { + t.Errorf("escaped pipes were not restored: %q", got) + } +} + +// Verifies the single normalization step resolves an escaped pipe and drops code-span backticks in +// one pass, and leaves other Markdown alone. Every consumer of a skill table goes through this +// function, so a change here would silently move help, list and the generated catalog apart. +func TestNormalizeCellTextResolvesEscapesAndCodeSpans(t *testing.T) { + got := NormalizeCellText(" `Press` " + `\|` + " `KeyDown`, see **Actions** ") + + if got != "Press | KeyDown, see **Actions**" { + t.Errorf("unexpected normalization: %q", got) + } +} + +// Verifies a skill covering several commands documents each one from its own subsection, and that a +// parameter table outside the Parameters section is ignored. +func TestParseSkillReadsAMultiToolSkill(t *testing.T) { + docs := ParseSkill(multiToolSkill) + + if len(docs) != 2 { + t.Fatalf("expected the two documented commands, got %v", docs) + } + enable, ok := docs["enable-pause-point"] + if !ok { + t.Fatalf("enable-pause-point is missing: %v", docs) + } + if enable.ToolDescription != "Enable a pause point so Unity pauses when that code path is reached" { + t.Errorf("tool description not taken from the line under the heading: %q", enable.ToolDescription) + } + if got := enable.ParamDescriptions["max-history"]; got != "Maximum number of captured hit frames to retain (1-100)" { + t.Errorf("--max-history description: %q", got) + } + if got := docs["clear-watch"].ParamDescriptions["all"]; got != "Clear every registered watch expression" { + t.Errorf("--all description: %q", got) + } + if _, ok := docs["pause-point"]; ok { + t.Error("a multi-tool skill must not register its own skill name as a tool") + } +} + +// Verifies a CRLF checkout parses identically to an LF one, since Windows checkouts rewrite line +// endings and help text must not depend on which platform read the file. +func TestParseSkillToleratesCRLFLineEndings(t *testing.T) { + crlf := strings.ReplaceAll(singleToolSkill, "\n", "\r\n") + + if got := ParseSkill(crlf); got["simulate-keyboard"].ParamDescriptions["duration"] != "Hold duration in seconds." { + t.Errorf("CRLF content parsed differently: %v", got) + } + crlfMultiTool := strings.ReplaceAll(multiToolSkill, "\n", "\r\n") + if got := ParseSkill(crlfMultiTool); len(got) != 2 { + t.Errorf("CRLF multi-tool content parsed differently: %v", got) + } +} + +// Verifies a leading byte order mark does not hide the frontmatter, which would otherwise drop the +// tool name and silently document nothing. +func TestParseSkillToleratesAByteOrderMark(t *testing.T) { + docs := ParseSkill(byteOrderMark + singleToolSkill) + + if _, ok := docs["simulate-keyboard"]; !ok { + t.Errorf("a BOM must not hide the frontmatter: %v", docs) + } +} + +// Verifies a skill with no parameter table still reports its tool description, which is the only +// documentation a tool with no parameters has. +func TestParseSkillKeepsTheDescriptionOfATablelessSkill(t *testing.T) { + docs := ParseSkill(`--- +name: uloop-focus-window +description: "Bring the Unity Editor window to front." +--- + +# uloop focus-window +`) + + toolDocs, ok := docs["focus-window"] + if !ok { + t.Fatalf("focus-window is missing: %v", docs) + } + if toolDocs.ToolDescription != "Bring the Unity Editor window to front." { + t.Errorf("tool description: %q", toolDocs.ToolDescription) + } + if len(toolDocs.ParamDescriptions) != 0 { + t.Errorf("a skill with no table documents no parameters: %v", toolDocs.ParamDescriptions) + } +} + +// Verifies a skill whose frontmatter names no tool documents nothing rather than guessing a name. +func TestParseSkillIgnoresASkillWithNoToolName(t *testing.T) { + docs := ParseSkill(`--- +name: some-unrelated-skill +description: "Not a uloop tool skill." +--- + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| ` + "`--flag`" + ` | flag | - | Should not be registered | +`) + + if len(docs) != 0 { + t.Errorf("expected no documented tool, got %v", docs) + } +} diff --git a/cli/common/skilldocs/skill_docs.go b/cli/common/skilldocs/skill_docs.go new file mode 100644 index 0000000000..91cf04fe9c --- /dev/null +++ b/cli/common/skilldocs/skill_docs.go @@ -0,0 +1,19 @@ +// Package skilldocs reads the parameter tables out of the package's own SKILL.md files and uses +// them as the source of truth for help text. Descriptions used to live in three hand-maintained +// places (the skill prose, Unity's schema attributes, and the catalog embedded in this binary), so +// editing one left the others stale. Reading the skill at render time removes that drift for good: +// the file an agent reads and the help an agent runs cannot disagree. +// +// This package intentionally depends only on tools, tooldocs, skillscan and vibelog. clicore +// already imports tools and tooldocs, so importing it here would create an import cycle. +package skilldocs + +// ToolDocs is what one skill file says about one tool. +type ToolDocs struct { + // ToolDescription is the tool's own summary line (frontmatter description, or the line under + // the tool's heading in a multi-tool skill). Empty when the skill states none. + ToolDescription string + // ParamDescriptions is keyed by CLI option name without the leading "--", which is what + // tooldocs.OptionNameForProperty produces for a schema property. + ParamDescriptions map[string]string +} diff --git a/cli/common/tooldocs/enum_defaults.go b/cli/common/tooldocs/enum_defaults.go new file mode 100644 index 0000000000..7899adfcb2 --- /dev/null +++ b/cli/common/tooldocs/enum_defaults.go @@ -0,0 +1,35 @@ +package tooldocs + +// EnumValueForNumericDefault converts a numeric default on an enum property into the member name +// that has to be typed on the command line. Unity's schema generator serializes a C# enum default +// as its ordinal, so both option listings would otherwise report "default: 0" for a parameter that +// only accepts names such as "Press". +// +// The conversion assumes the enum is zero-based and contiguous, which is what a name lookup by +// ordinal requires. A value outside the listed range yields no conversion so the raw number is +// shown instead of a wrong member name. +func EnumValueForNumericDefault(defaultValue any, values []string) (string, bool) { + if len(values) == 0 || defaultValue == nil { + return "", false + } + + switch value := defaultValue.(type) { + case int: + return enumValueAtIndex(value, values) + case float64: + index := int(value) + if value != float64(index) { + return "", false + } + return enumValueAtIndex(index, values) + default: + return "", false + } +} + +func enumValueAtIndex(index int, values []string) (string, bool) { + if index < 0 || index >= len(values) { + return "", false + } + return values[index], true +} diff --git a/cli/common/tooldocs/enum_defaults_test.go b/cli/common/tooldocs/enum_defaults_test.go new file mode 100644 index 0000000000..ae302a0d55 --- /dev/null +++ b/cli/common/tooldocs/enum_defaults_test.go @@ -0,0 +1,71 @@ +package tooldocs + +import ( + "strings" + "testing" + + "github.com/hatayama/unity-cli-loop/common/tools" +) + +// Verifies an enum property whose cached default is the C# enum's ordinal renders the member name +// in --help. Unity's schema cache serializes enum defaults as numbers, so --help showed +// "default: 0" for a value that has to be passed as "Press". +func TestOptionDescriptionRendersEnumDefaultByName(t *testing.T) { + property := tools.ToolProperty{ + Type: "string", + Description: "Keyboard action", + DefaultValue: float64(0), + Enum: []string{"Press", "KeyDown", "KeyUp", "ReleaseAll"}, + } + + description := optionDescription("simulate-keyboard", "Action", property) + if !strings.Contains(description, "default: Press") { + t.Errorf("description = %q, want it to contain %q", description, "default: Press") + } +} + +// Verifies a string default that already names an enum member is passed through untouched. +func TestOptionDescriptionKeepsNamedEnumDefault(t *testing.T) { + property := tools.ToolProperty{ + Type: "string", + Description: "Pause point mode", + DefaultValue: "single-shot", + Enum: []string{"single-shot", "repeat"}, + } + + description := optionDescription("enable-pause-point", "Mode", property) + if !strings.Contains(description, "default: single-shot") { + t.Errorf("description = %q, want it to contain %q", description, "default: single-shot") + } +} + +// Verifies a numeric default on a property with no enum stays numeric, so --timeout-seconds does +// not get mistaken for an enum ordinal. +func TestOptionDescriptionKeepsNumericDefaultWithoutEnum(t *testing.T) { + property := tools.ToolProperty{ + Type: "number", + Description: "Timeout", + DefaultValue: float64(30), + Enum: nil, + } + + description := optionDescription("await-pause-point", "TimeoutSeconds", property) + if !strings.Contains(description, "default: 30") { + t.Errorf("description = %q, want it to contain %q", description, "default: 30") + } +} + +// Verifies an ordinal outside the enum's range is left as-is rather than reported as some other +// member: guessing a name there would be worse than showing the raw value. +func TestEnumValueForNumericDefaultRejectsOutOfRangeOrdinal(t *testing.T) { + if value, ok := EnumValueForNumericDefault(float64(7), []string{"Press", "KeyDown"}); ok { + t.Errorf("out-of-range ordinal resolved to %q, want no conversion", value) + } +} + +// Verifies a fractional default is not treated as an ordinal, since no enum member can match it. +func TestEnumValueForNumericDefaultRejectsFractionalOrdinal(t *testing.T) { + if value, ok := EnumValueForNumericDefault(0.5, []string{"Press", "KeyDown"}); ok { + t.Errorf("fractional ordinal resolved to %q, want no conversion", value) + } +} diff --git a/cli/common/tooldocs/pause_point_cli_options.go b/cli/common/tooldocs/pause_point_cli_options.go new file mode 100644 index 0000000000..e45cfd76a3 --- /dev/null +++ b/cli/common/tooldocs/pause_point_cli_options.go @@ -0,0 +1,130 @@ +package tooldocs + +import "strings" + +// enable-pause-point accepts six orchestration flags that exist only in the CLI: they are parsed +// out of the argv before the Unity-side EnablePausePointSchema is consulted, so nothing in the +// tool schema describes them. Both listings that document a tool's options — the dispatcher's +// `--help` table and the project runner's `uloop list` output — therefore have to add them by +// hand, and they drifted: `uloop list` documented all six while `--help` documented none. +// Defining them once here makes both listings read the same table. +const ( + PausePointEnableAwaitFlagName = "await" + PausePointCapturedVariablesFlagName = "captured-variables" + PausePointCapturedVariableNamesFlagName = "captured-variable-names" + PausePointExpectFlagName = "expect" + PausePointTriggerFlagName = "trigger" + PausePointResumePlayFlagName = "resume-play" +) + +// Accepted --captured-variables values. Declared here because they appear in the option listings; +// the runner's own mode type is defined from these constants so the two cannot drift. +const ( + PausePointCapturedVariablesModeFull = "full" + PausePointCapturedVariablesModeNames = "names" +) + +// pausePointEnableCommandName is private to this package: importing the clicore package that owns +// the command-name constants would be an import cycle, the same reason +// executeDynamicCodeCommandName is declared locally. +const pausePointEnableCommandName = "enable-pause-point" + +// PausePointCLIOnlyOption describes one CLI-only pause-point flag in the shape both listings need: +// `--help` renders FlagName/Type/Description, and `uloop list` additionally reports Type and Values +// as structured fields. +type PausePointCLIOnlyOption struct { + FlagName string + Type string + Description string + Values []string +} + +// PausePointEnableCLIOnlyOptions returns enable-pause-point's CLI-only flags. A fresh slice is +// built per call so a caller that sorts or appends cannot mutate the shared table. +func PausePointEnableCLIOnlyOptions() []PausePointCLIOnlyOption { + return []PausePointCLIOnlyOption{ + { + FlagName: PausePointEnableAwaitFlagName, + Type: "boolean", + Description: "Wait for the marker to be hit (or time out) after enabling, in a single call, " + + "instead of a separate await-pause-point call", + }, + { + FlagName: PausePointCapturedVariablesFlagName, + Type: "string", + Description: "Requires --await. Same as await-pause-point's --captured-variables", + Values: []string{ + PausePointCapturedVariablesModeFull, + PausePointCapturedVariablesModeNames, + }, + }, + { + FlagName: PausePointCapturedVariableNamesFlagName, + Type: "string", + Description: "Requires --await. Same as await-pause-point's --captured-variable-names", + }, + { + FlagName: PausePointExpectFlagName, + Type: "string", + Description: "Requires --await. Same as await-pause-point's --expect (repeatable)", + }, + { + FlagName: PausePointTriggerFlagName, + Type: "string", + Description: "Requires --await. Same as await-pause-point's --trigger", + }, + { + FlagName: PausePointResumePlayFlagName, + Type: "boolean", + Description: "Requires --await. After confirming the marker is armed, resume PlayMode if " + + "paused (before --trigger), so a paused-arm workflow can fire input in one call", + }, + } +} + +func appendPausePointEnableCLIOnlyOptionHelpEntries( + toolName string, + entries []OptionHelpEntry, +) []OptionHelpEntry { + if toolName != pausePointEnableCommandName { + return entries + } + + for _, option := range PausePointEnableCLIOnlyOptions() { + optionName := "--" + option.FlagName + if hasOptionHelpEntry(entries, optionName) { + continue + } + entries = append(entries, OptionHelpEntry{ + Name: optionName, + Usage: pausePointCLIOnlyOptionUsage(optionName, option), + Description: pausePointCLIOnlyOptionDescription(option), + }) + } + return entries +} + +func pausePointCLIOnlyOptionUsage(optionName string, option PausePointCLIOnlyOption) string { + if option.Type == "boolean" { + return optionName + } + return optionName + " " +} + +// pausePointCLIOnlyOptionDescription appends the accepted values the same way the schema-driven +// rows do, so a CLI-only row is indistinguishable in shape from a schema-derived one. +func pausePointCLIOnlyOptionDescription(option PausePointCLIOnlyOption) string { + if len(option.Values) == 0 { + return option.Description + } + return option.Description + "; values: " + strings.Join(option.Values, optionValuesSeparator) +} + +func hasOptionHelpEntry(entries []OptionHelpEntry, name string) bool { + for _, entry := range entries { + if entry.Name == name { + return true + } + } + return false +} diff --git a/cli/common/tooldocs/pause_point_cli_options_test.go b/cli/common/tooldocs/pause_point_cli_options_test.go new file mode 100644 index 0000000000..fbbfab204b --- /dev/null +++ b/cli/common/tooldocs/pause_point_cli_options_test.go @@ -0,0 +1,80 @@ +package tooldocs + +import ( + "strings" + "testing" + + "github.com/hatayama/unity-cli-loop/common/tools" +) + +// Verifies enable-pause-point's --help lists every CLI-only pause-point flag. The schema-driven +// loop cannot produce them because none of them exist in the Unity-side EnablePausePointSchema, +// so --help documented none of the six while `uloop list` documented all six. +func TestVisibleOptionHelpEntriesIncludePausePointEnableCLIOnlyOptions(t *testing.T) { + tool, ok := tools.Find(tools.LoadDefault(), pausePointEnableCommandName) + if !ok { + t.Fatalf("embedded catalog has no %q tool", pausePointEnableCommandName) + } + + entries := VisibleOptionHelpEntriesForTool(tool) + for _, option := range PausePointEnableCLIOnlyOptions() { + optionName := "--" + option.FlagName + entry, found := findOptionHelpEntry(entries, optionName) + if !found { + t.Fatalf("enable-pause-point --help is missing CLI-only option %s", optionName) + } + if entry.Description == "" { + t.Errorf("option %s has no description in --help", optionName) + } + } +} + +// Verifies boolean CLI-only flags render without a value placeholder and valued ones render with +// one, so the help usage column matches how the flags are actually passed. +func TestPausePointEnableCLIOnlyOptionHelpUsage(t *testing.T) { + tool, ok := tools.Find(tools.LoadDefault(), pausePointEnableCommandName) + if !ok { + t.Fatalf("embedded catalog has no %q tool", pausePointEnableCommandName) + } + + entries := VisibleOptionHelpEntriesForTool(tool) + + awaitEntry, found := findOptionHelpEntry(entries, "--"+PausePointEnableAwaitFlagName) + if !found { + t.Fatalf("enable-pause-point --help is missing --%s", PausePointEnableAwaitFlagName) + } + if awaitEntry.Usage != "--"+PausePointEnableAwaitFlagName { + t.Errorf("boolean flag usage = %q, want %q", awaitEntry.Usage, "--"+PausePointEnableAwaitFlagName) + } + + triggerEntry, found := findOptionHelpEntry(entries, "--"+PausePointTriggerFlagName) + if !found { + t.Fatalf("enable-pause-point --help is missing --%s", PausePointTriggerFlagName) + } + if !strings.HasSuffix(triggerEntry.Usage, " ") { + t.Errorf("valued flag usage = %q, want a value placeholder", triggerEntry.Usage) + } +} + +// Verifies the CLI-only option table is not applied to unrelated tools, which would advertise +// pause-point orchestration flags on commands that reject them. +func TestVisibleOptionHelpEntriesOmitPausePointCLIOnlyOptionsForOtherTools(t *testing.T) { + tool, ok := tools.Find(tools.LoadDefault(), compileCommandName) + if !ok { + t.Fatalf("embedded catalog has no %q tool", compileCommandName) + } + + entries := VisibleOptionHelpEntriesForTool(tool) + if _, found := findOptionHelpEntry(entries, "--"+PausePointEnableAwaitFlagName); found { + t.Errorf("%s --help advertises --%s", compileCommandName, PausePointEnableAwaitFlagName) + } +} + +func findOptionHelpEntry(entries []OptionHelpEntry, name string) (OptionHelpEntry, bool) { + for _, entry := range entries { + if entry.Name == name { + return entry, true + } + } + return OptionHelpEntry{}, false +} diff --git a/cli/common/tooldocs/skill_guidance.go b/cli/common/tooldocs/skill_guidance.go new file mode 100644 index 0000000000..6c01f9cbdb --- /dev/null +++ b/cli/common/tooldocs/skill_guidance.go @@ -0,0 +1,55 @@ +package tooldocs + +// commandSkillNames maps a command to the agent skill that documents it. A static table rather than +// a name derived from the command: `enable-pause-point` is documented by `uloop-pause-point`, not by +// a `uloop-enable-pause-point` skill that does not exist, and four commands share that one skill. +// A command absent from this table gets no guidance line at all, so custom commands are never +// pointed at a skill nobody installed. +var commandSkillNames = map[string]string{ + "clear-console": "uloop-clear-console", + "compile": "uloop-compile", + "control-play-mode": "uloop-control-play-mode", + "execute-dynamic-code": "uloop-execute-dynamic-code", + "find-game-objects": "uloop-find-game-objects", + "focus-window": "uloop-focus-window", + "get-hierarchy": "uloop-get-hierarchy", + "get-logs": "uloop-get-logs", + "raycast": "uloop-raycast", + "record-input": "uloop-record-input", + "replay-input": "uloop-replay-input", + "run-tests": "uloop-run-tests", + "screenshot": "uloop-screenshot", + "set-game-view-size": "uloop-set-game-view-size", + "simulate-keyboard": "uloop-simulate-keyboard", + "simulate-mouse-input": "uloop-simulate-mouse-input", + "simulate-mouse-ui": "uloop-simulate-mouse-ui", + + "launch": "uloop-launch", + + // One skill covers the four pause-point commands and the three watch commands: watch + // expressions are documented by the pause-point skill's references/watch-expressions.md. + "enable-pause-point": "uloop-pause-point", + "clear-pause-point": "uloop-pause-point", + "await-pause-point": "uloop-pause-point", + "pause-point-status": "uloop-pause-point", + "enable-watch": "uloop-pause-point", + "clear-watch": "uloop-pause-point", + "get-watch-values": "uloop-pause-point", +} + +// SkillGuidanceLine returns the closing line of a command's --help output: an instruction to load +// the skill that documents it. `--help` can only list option names and one-line summaries, so the +// workflow rules, response shapes, and failure diagnoses live in the skill; without this line an +// agent that found the command through --help has no way to learn the skill exists. +// +// Phrased as an instruction rather than a cross-reference, because a line that merely mentions a +// document is easy to read past. It names what the skill adds rather than referring to the options +// above, because commands such as focus-window have no options for that phrasing to point at. +func SkillGuidanceLine(command string) (string, bool) { + skillName, ok := commandSkillNames[command] + if !ok { + return "", false + } + return "Load the " + skillName + + " skill for workflow rules and response fields that --help does not cover.", true +} diff --git a/cli/common/tooldocs/skill_guidance_test.go b/cli/common/tooldocs/skill_guidance_test.go new file mode 100644 index 0000000000..f818aa06a6 --- /dev/null +++ b/cli/common/tooldocs/skill_guidance_test.go @@ -0,0 +1,126 @@ +package tooldocs + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// Verifies a command with a matching skill gets an instruction to load it, and that the instruction +// names the skill exactly. +func TestSkillGuidanceLineNamesTheMatchingSkill(t *testing.T) { + line, ok := SkillGuidanceLine("simulate-keyboard") + if !ok { + t.Fatalf("simulate-keyboard has no skill guidance line") + } + if !strings.Contains(line, "uloop-simulate-keyboard") { + t.Errorf("guidance line = %q, want it to name uloop-simulate-keyboard", line) + } +} + +// Verifies all four pause-point commands point at the single uloop-pause-point skill rather than a +// per-command skill name derived from the command, which would not exist. +func TestSkillGuidanceLineMapsEveryPausePointCommandToOneSkill(t *testing.T) { + commands := []string{ + "enable-pause-point", + "clear-pause-point", + "await-pause-point", + "pause-point-status", + } + + for _, command := range commands { + line, ok := SkillGuidanceLine(command) + if !ok { + t.Errorf("%s has no skill guidance line", command) + continue + } + if !strings.Contains(line, "uloop-pause-point") { + t.Errorf("%s guidance line = %q, want it to name uloop-pause-point", command, line) + } + } +} + +// Verifies a command with no skill gets no guidance line, so custom commands are not pointed at a +// skill that does not exist. +func TestSkillGuidanceLineOmittedForUnmappedCommands(t *testing.T) { + if line, ok := SkillGuidanceLine("my-custom-command"); ok { + t.Errorf("unmapped command produced guidance line %q", line) + } +} + +// Verifies every skill named in the guidance map exists as a SKILL.md frontmatter name, so the +// instruction can never tell an agent to load a skill that was renamed or removed. +func TestSkillGuidanceMapNamesExistingSkills(t *testing.T) { + existing := installedSkillNames(t) + + for command, skillName := range commandSkillNames { + if !existing[skillName] { + t.Errorf("command %s points at skill %q, which no SKILL.md declares", command, skillName) + } + } +} + +func installedSkillNames(t *testing.T) map[string]bool { + t.Helper() + + names := map[string]bool{} + for _, pattern := range []string{ + filepath.Join("Packages", "src", "Editor", "FirstPartyTools", "*", "Skill", "SKILL.md"), + filepath.Join("Packages", "src", "Editor", "CliOnlyTools~", "*", "Skill", "SKILL.md"), + } { + matches, err := filepath.Glob(filepath.Join(repositoryRoot(t), pattern)) + if err != nil { + t.Fatalf("failed to glob %s: %v", pattern, err) + } + for _, match := range matches { + names[skillFrontmatterName(t, match)] = true + } + } + + if len(names) == 0 { + t.Fatalf("found no SKILL.md files to validate against") + } + return names +} + +func skillFrontmatterName(t *testing.T, path string) string { + t.Helper() + + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read %s: %v", path, err) + } + + for _, line := range strings.Split(string(content), "\n") { + if strings.HasPrefix(line, "name:") { + return strings.TrimSpace(strings.TrimPrefix(line, "name:")) + } + } + + t.Fatalf("%s has no frontmatter name", path) + return "" +} + +// repositoryRoot walks up from the test's working directory until the repository layout is visible, +// because this module's tests run from their own package directory. +func repositoryRoot(t *testing.T) string { + t.Helper() + + directory, err := os.Getwd() + if err != nil { + t.Fatalf("failed to resolve current directory: %v", err) + } + + for { + if _, err := os.Stat(filepath.Join(directory, "Packages", "src", "Editor")); err == nil { + return directory + } + + parent := filepath.Dir(directory) + if parent == directory { + t.Fatalf("failed to find the repository root from %s", directory) + } + directory = parent + } +} diff --git a/cli/common/tooldocs/tool_option_help.go b/cli/common/tooldocs/tool_option_help.go index e72f80a9ff..121feab89b 100644 --- a/cli/common/tooldocs/tool_option_help.go +++ b/cli/common/tooldocs/tool_option_help.go @@ -19,6 +19,9 @@ const ( const DynamicCodeFileOptionDescription = "Read C# code from a file instead of --code when shell quoting would alter multiline code" +// optionValuesSeparator joins the accepted values of an option in help output. +const optionValuesSeparator = "|" + const ( compileCommandName = "compile" executeDynamicCodeCommandName = "execute-dynamic-code" @@ -49,6 +52,7 @@ func VisibleOptionHelpEntriesForTool(tool tools.ToolDefinition) []OptionHelpEntr } entries = appendDynamicCodeFileOptionHelpEntry(tool, entries) + entries = appendPausePointEnableCLIOnlyOptionHelpEntries(tool.Name, entries) sort.Slice(entries, func(i int, j int) bool { return entries[i].Name < entries[j].Name }) @@ -89,38 +93,40 @@ func optionDescription(toolName string, propertyName string, property tools.Tool if description := OptionSummary(toolName, propertyName, property); description != "" { parts = append(parts, description) } - if propertyDefault := property.EffectiveDefault(); propertyDefault != nil { - parts = append(parts, "default: "+defaultValueText(propertyDefault)) + // An empty-string default is what Unity reports for any unset string parameter, so rendering it + // would print a bare "default: " with nothing after it. + if propertyDefault := property.EffectiveDefault(); propertyDefault != nil && propertyDefault != "" { + parts = append(parts, "default: "+defaultValueText(propertyDefault, property.Enum)) } if len(property.Enum) > 0 { - parts = append(parts, "values: "+strings.Join(property.Enum, "|")) + parts = append(parts, "values: "+strings.Join(property.Enum, optionValuesSeparator)) } return strings.Join(parts, "; ") } -func defaultValueText(value any) string { +func defaultValueText(value any, enumValues []string) string { if boolValue, ok := value.(bool); ok { if boolValue { return "enabled" } return "disabled" } + if enumValue, ok := EnumValueForNumericDefault(value, enumValues); ok { + return enumValue + } return fmt.Sprint(value) } +// OptionSummary is the help text for one option. A description that came from a skill parameter table +// is printed verbatim, including for a negated boolean flag: those rows are already written from the +// flag's point of view ("Exclude component information" for --no-include-components). +// +// Only a description with no skill behind it - a project-local custom command - is synthesized. Its +// author wrote the property in the positive sense, so printing it against a --no- flag would +// read as the opposite of what the flag does. The branch is on where the text came from, never on how +// the text is worded. func OptionSummary(toolName string, propertyName string, property tools.ToolProperty) string { - if IsNegatedBooleanProperty(property) { - if isRunTestsSaveBeforeRunOption(toolName, propertyName, property) { - return "Fail before execution if unsaved editor changes remain instead of auto-saving them" - } - if isCompileReloadExternalSceneChangesOption(toolName, propertyName, property) { - return "Stop before execution if open Scene files changed externally instead of auto-reloading them" - } - summary := FirstHelpLine(property.Description) - normalizedSummary := strings.ToLower(summary) - if strings.HasPrefix(normalizedSummary, "disable ") || strings.HasPrefix(normalizedSummary, "do not ") { - return summary - } + if IsNegatedBooleanProperty(property) && !property.SkillSourcedDescription { return "Disable " + pascalToWords(propertyName) } return FirstHelpLine(property.Description) @@ -142,10 +148,8 @@ func appendDynamicCodeFileOptionHelpEntry(tool tools.ToolDefinition, entries []O if tool.Name != executeDynamicCodeCommandName { return entries } - for _, entry := range entries { - if entry.Name == DynamicCodeFileOptionName { - return entries - } + if hasOptionHelpEntry(entries, DynamicCodeFileOptionName) { + return entries } return append(entries, OptionHelpEntry{ Name: DynamicCodeFileOptionName, diff --git a/cli/common/tooldocs/tool_option_help_test.go b/cli/common/tooldocs/tool_option_help_test.go new file mode 100644 index 0000000000..100971c742 --- /dev/null +++ b/cli/common/tooldocs/tool_option_help_test.go @@ -0,0 +1,90 @@ +package tooldocs + +import ( + "testing" + + "github.com/hatayama/unity-cli-loop/common/tools" +) + +// Verifies a negated boolean whose description came from a skill parameter table prints that text +// verbatim. Those rows are written from the flag's point of view, and the summary this replaced +// discarded them in favor of a synthesized "Disable ". +func TestOptionSummaryKeepsASkillSourcedNegatedBooleanDescription(t *testing.T) { + summary := OptionSummary("get-hierarchy", "IncludeComponents", tools.ToolProperty{ + Type: "boolean", + Default: true, + Description: "Exclude component information", + SkillSourcedDescription: true, + }) + + if summary != "Exclude component information" { + t.Errorf("a skill-sourced description must be printed as written: %q", summary) + } +} + +// Verifies a negated boolean with no skill behind it still gets a synthesized summary. A custom +// command's author writes the property in the positive sense, so printing "Show my overlay" against +// --no-show-my-overlay would state the opposite of what the flag does. +func TestOptionSummarySynthesizesForANegatedBooleanWithNoSkill(t *testing.T) { + summary := OptionSummary("my-custom-command", "ShowMyOverlay", tools.ToolProperty{ + Type: "boolean", + Default: true, + Description: "Show my overlay", + }) + + if summary != "Disable show my overlay" { + t.Errorf("a description with no skill behind it must be synthesized: %q", summary) + } +} + +// Verifies the branch is on where the text came from, not on how it is worded: a description that +// happens to start with "Disable" is no longer what decides the outcome. +func TestOptionSummaryIgnoresTheWordingOfTheDescription(t *testing.T) { + summary := OptionSummary("my-custom-command", "WaitForThing", tools.ToolProperty{ + Type: "boolean", + Default: true, + Description: "Disable the wait that this custom command performs", + }) + + if summary != "Disable wait for thing" { + t.Errorf("wording must not decide the branch: %q", summary) + } +} + +// Verifies a description filled in from the embedded catalog is treated as skill-sourced too. The +// catalog is generated from the same parameter tables, so a cache carrying Unity's placeholder must end +// up with the table's wording rather than a synthesized summary. +func TestOptionSummaryKeepsANegatedBooleanDescriptionFilledFromTheEmbeddedCatalog(t *testing.T) { + catalog := tools.ApplyEmbeddedDescriptionFallback(tools.ToolCatalog{Tools: []tools.ToolDefinition{{ + Name: "get-hierarchy", + ParameterSchema: tools.ToolInputSchema{Properties: map[string]tools.ToolProperty{ + "IncludeComponents": {Type: "boolean", Default: true, Description: "Parameter: IncludeComponents"}, + }}, + }}}) + + property := catalog.Tools[0].EffectiveInputSchema().Properties["IncludeComponents"] + if property.Description == "" || property.Description == "Parameter: IncludeComponents" { + t.Fatalf("the embedded catalog did not supply a description: %q", property.Description) + } + summary := OptionSummary("get-hierarchy", "IncludeComponents", property) + + if summary != property.Description { + t.Errorf("the filled-in description was not printed as written: %q", summary) + } + if summary == "Disable include components" { + t.Errorf("the synthesized summary replaced the embedded description: %q", summary) + } +} + +// Verifies a plain (non-negated) option is unaffected by provenance, since its description already +// reads correctly against its own flag name. +func TestOptionSummaryKeepsPlainOptionDescriptions(t *testing.T) { + summary := OptionSummary("my-custom-command", "Amount", tools.ToolProperty{ + Type: "number", + Description: "How much to apply", + }) + + if summary != "How much to apply" { + t.Errorf("a plain option's description must be printed as written: %q", summary) + } +} diff --git a/cli/common/tools/catalog.go b/cli/common/tools/catalog.go index 426a772a6f..777820f161 100644 --- a/cli/common/tools/catalog.go +++ b/cli/common/tools/catalog.go @@ -21,15 +21,10 @@ func Load(projectRoot string, internalToolNames map[string]bool) (ToolCatalog, e return cache, nil } - content, err := embeddedTools.ReadFile(defaultToolsFile) + cache, err := decodeEmbeddedCatalog() if err != nil { return ToolCatalog{}, err } - - var cache ToolCatalog - if err := json.Unmarshal(content, &cache); err != nil { - return ToolCatalog{}, err - } return FilterInternalTools(cache, internalToolNames), nil } @@ -44,20 +39,40 @@ func LoadProjectCache(projectRoot string, internalToolNames map[string]bool) (To if json.Unmarshal(content, &cache) != nil { return ToolCatalog{}, false } - return FilterInternalTools(cache, internalToolNames), true + // Unity's generator produces placeholder descriptions, so a synced cache alone would strip every + // option's help text; the embedded catalog fills those gaps in. + return ApplyEmbeddedDescriptionFallback(FilterInternalTools(cache, internalToolNames)), true } func LoadDefault() ToolCatalog { - content, err := embeddedTools.ReadFile(defaultToolsFile) + cache, err := decodeEmbeddedCatalog() if err != nil { return ToolCatalog{} } + return cache +} - var cache ToolCatalog - if json.Unmarshal(content, &cache) != nil { - return ToolCatalog{} +// decodeEmbeddedCatalog reads the catalog compiled into this binary. Its description text is +// generated from the package's skill parameter tables, so every property it carries is marked as +// skill-sourced and renders verbatim. +func decodeEmbeddedCatalog() (ToolCatalog, error) { + content, err := embeddedTools.ReadFile(defaultToolsFile) + if err != nil { + return ToolCatalog{}, err } - return cache + + cache := ToolCatalog{} + if err := json.Unmarshal(content, &cache); err != nil { + return ToolCatalog{}, err + } + for _, tool := range cache.Tools { + schema := tool.EffectiveInputSchema() + for propertyName, property := range schema.Properties { + property.SkillSourcedDescription = true + schema.Properties[propertyName] = property + } + } + return cache, nil } func Find(cache ToolCatalog, name string) (ToolDefinition, bool) { diff --git a/cli/common/tools/default-tools.json b/cli/common/tools/default-tools.json index 10ee3bc3e2..df36cb6ffd 100644 --- a/cli/common/tools/default-tools.json +++ b/cli/common/tools/default-tools.json @@ -2,22 +2,22 @@ "tools": [ { "name": "compile", - "description": "Execute Unity project compilation", + "description": "Compile the Unity project and report errors/warnings. Use after C# edits.", "inputSchema": { "type": "object", "properties": { "ForceRecompile": { "type": "boolean", - "description": "Force full recompilation" + "description": "Full recompile plus domain reload. Almost never needed: a plain compile already detects externally edited files, and the forced reload can freeze large projects and come back as COMPILE_RESULT_UNKNOWN." }, "WaitForDomainReload": { "type": "boolean", - "description": "Wait for domain reload completion before returning", + "description": "Return before Domain Reload completion", "default": true }, "ReloadExternalSceneChanges": { "type": "boolean", - "description": "Automatically reload or save open Scene files changed outside Unity before compiling", + "description": "Stop before compilation if open Scene files changed externally instead of auto-reloading them", "default": true } } @@ -25,13 +25,13 @@ }, { "name": "get-logs", - "description": "Retrieve logs from Unity Console", + "description": "Read current Unity Console entries from a running Editor. Use during bug investigation after compile, tests, PlayMode, dynamic code, or immediately after `uloop-pause-point`.", "inputSchema": { "type": "object", "properties": { "LogType": { "type": "string", - "description": "Log type filter", + "description": "Log type filter: Error, Warning, Log, All", "enum": [ "Error", "Warning", @@ -67,13 +67,13 @@ }, { "name": "run-tests", - "description": "Execute Unity Test Runner", + "description": "Run Unity Test Runner and report detailed results. Use for EditMode/PlayMode tests, change verification, or failure diagnosis.", "inputSchema": { "type": "object", "properties": { "TestMode": { "type": "string", - "description": "Test mode", + "description": "Test mode: EditMode, PlayMode", "enum": [ "EditMode", "PlayMode" @@ -82,7 +82,7 @@ }, "FilterType": { "type": "string", - "description": "Filter type", + "description": "Filter type: all, exact, regex, assembly", "enum": [ "all", "exact", @@ -93,16 +93,16 @@ }, "FilterValue": { "type": "string", - "description": "Filter value" + "description": "Filter value (test name, pattern, or assembly)" }, "SaveBeforeRun": { "type": "boolean", - "description": "Save unsaved loaded Scene changes and current Prefab Stage changes before running tests", + "description": "Fail before test execution if unsaved editor changes remain instead of auto-saving them", "default": true }, "TimeoutSeconds": { "type": "integer", - "description": "Maximum seconds to wait for Unity Test Runner RunFinished before canceling the await and freeing the tool slot", + "description": "Maximum seconds to wait for RunFinished before canceling the await (max 1500). Increase for long suites; on timeout the Test Runner may still be running until stop handling lands", "default": 600 } } @@ -110,7 +110,7 @@ }, { "name": "clear-console", - "description": "Clear Unity console logs", + "description": "Clear Unity Console entries. Use before compile, tests, or debugging when stale logs would hide the current result.", "inputSchema": { "type": "object", "properties": { @@ -123,7 +123,7 @@ }, { "name": "focus-window", - "description": "Bring Unity Editor window to front", + "description": "Bring the Unity Editor window to front. Use when Unity must be visible for visual checks or user-facing interaction.", "inputSchema": { "type": "object", "properties": {} @@ -131,13 +131,13 @@ }, { "name": "get-hierarchy", - "description": "Get Unity Hierarchy structure", + "description": "Get the Unity scene hierarchy as a structured tree. Use for parent-child structure, descendants, roots, or subtrees under objects the user currently selected.", "inputSchema": { "type": "object", "properties": { "RootPath": { "type": "string", - "description": "Root GameObject path" + "description": "Root GameObject path to start from" }, "MaxDepth": { "type": "integer", @@ -146,26 +146,26 @@ }, "IncludeComponents": { "type": "boolean", - "description": "Include component information", + "description": "Exclude component information", "default": true }, "IncludeInactive": { "type": "boolean", - "description": "Include inactive GameObjects", + "description": "Exclude inactive GameObjects", "default": true }, "IncludePaths": { "type": "boolean", - "description": "Include path information" + "description": "Include full path information" }, "UseComponentsLut": { "type": "string", - "description": "Use LUT for components (auto|true|false)", + "description": "Use LUT for components (auto, true, false)", "default": "auto" }, "UseSelection": { "type": "boolean", - "description": "Use selected GameObject(s) as root(s). When true, RootPath is ignored.", + "description": "Use selected GameObject(s) as root(s). When set, --root-path is ignored.", "default": false } } @@ -173,7 +173,7 @@ }, { "name": "find-game-objects", - "description": "Find GameObjects with search criteria", + "description": "Find or inspect Unity GameObjects, especially objects the user currently selected in the Hierarchy. Use for details, components, tags, layers, or name/path searches.", "inputSchema": { "type": "object", "properties": { @@ -183,7 +183,7 @@ }, "SearchMode": { "type": "string", - "description": "Search mode", + "description": "Search mode: Exact, Path, Regex, Contains, Selected", "enum": [ "Exact", "Path", @@ -206,7 +206,7 @@ }, "Layer": { "type": "integer", - "description": "Layer filter" + "description": "Layer filter (layer number)" }, "MaxResults": { "type": "integer", @@ -219,20 +219,20 @@ }, "IncludeInheritedProperties": { "type": "boolean", - "description": "Include inherited properties" + "description": "Include inherited properties in results" } } } }, { "name": "screenshot", - "description": "Take a screenshot of Unity EditorWindow and save as PNG", + "description": "Capture Unity Editor windows or Game View rendering as PNG. Use for visual checks, debugging, documentation, or annotated UI element coordinates.", "inputSchema": { "type": "object", "properties": { "WindowName": { "type": "string", - "description": "Window name to capture (e.g., 'Game', 'Scene', 'Console', 'Inspector', 'Project', 'Hierarchy')", + "description": "Window name to capture (for example Game, Scene, Console, Inspector). Ignored when --capture-mode rendering. When the Game tab is Device Simulator and the title is Simulator, default Game falls back to Simulator.", "default": "Game" }, "ResolutionScale": { @@ -242,7 +242,7 @@ }, "MatchMode": { "type": "string", - "description": "Window name matching mode (all case-insensitive)", + "description": "Window name matching mode: exact, prefix, or contains. Ignored when --capture-mode rendering.", "enum": [ "exact", "prefix", @@ -257,7 +257,7 @@ }, "CaptureMode": { "type": "string", - "description": "Capture mode: window=capture EditorWindow including toolbar, rendering=capture game rendering only (PlayMode required). Rendering screenshots return ScreenshotToInputFormula for converting raw image pixels before calling simulate-mouse-input or raycast.", + "description": "window - capture EditorWindow including toolbar, rendering - capture game rendering only (PlayMode required). Rendering screenshots return ScreenshotToInputFormula for converting raw image pixels before calling simulate-mouse-input or raycast.", "enum": [ "window", "rendering" @@ -266,22 +266,22 @@ }, "AnnotateElements": { "type": "boolean", - "description": "Annotate interactive UI elements with index labels (A, B, C...) on the screenshot. Only works with CaptureMode=rendering in PlayMode. Response includes AnnotatedElements array with element metadata sorted by z-order.", + "description": "Annotate interactive UI elements with index labels and interaction hints (A / CLICK, B / DRAG, ...). The response includes an AnnotatedElements array with element metadata sorted by z-order. Only works with --capture-mode rendering in PlayMode.", "default": false }, "ElementsOnly": { "type": "boolean", - "description": "Return only annotation JSON without capturing a screenshot image. Requires AnnotateElements=true or AnnotateRaycastGrid=true, and CaptureMode=rendering in PlayMode.", + "description": "Return only annotated element JSON without capturing a screenshot image. Requires --annotate-elements or --annotate-raycast-grid, and --capture-mode rendering in PlayMode.", "default": false }, "AnnotateRaycastGrid": { "type": "boolean", - "description": "Annotate clustered 3D physics raycast candidates (PhysicsCollider entries in AnnotatedElements) on rendering screenshots. Uses Camera.main, Camera.main.cullingMask visibility, and the same top-left Game View coordinates as simulate-mouse-input.", + "description": "Annotate clustered 3D physics collider candidates as PhysicsCollider entries in AnnotatedElements. Uses Camera.main visibility and the same top-left Game View coordinates as simulate-mouse-input. Only works with --capture-mode rendering in PlayMode.", "default": false }, "RaycastLayerMask": { "type": "string", - "description": "Comma-separated physics layer names used by AnnotateRaycastGrid to narrow which layers are clustered. Hits are limited to layers also visible to Camera.main.cullingMask. When omitted, clusters against Physics.DefaultRaycastLayers.", + "description": "Comma-separated physics layer names to narrow which layers --annotate-raycast-grid clusters. Hits are limited to layers also visible to Camera.main.cullingMask. When omitted, clusters against Physics.DefaultRaycastLayers.", "default": "" } } @@ -289,17 +289,17 @@ }, { "name": "execute-dynamic-code", - "description": "Execute C# code in Unity Editor", + "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", "inputSchema": { "type": "object", "properties": { "Code": { "type": "string", - "description": "C# code to execute" + "description": "Inline C# statements to execute. Direct statements only; return is optional, and using directives may appear at the top of the snippet." }, "Parameters": { "type": "object", - "description": "Runtime parameters for execution" + "description": "Shell-quoted JSON object literal for reusing a snippet with varying data or keeping values outside the code. Values are exposed as parameters[\"param0\"], parameters[\"param1\"], and so on. Omit for most snippets; never pass a JSON string value." }, "CompileOnly": { "type": "boolean", @@ -308,7 +308,7 @@ }, "WaitForDomainReload": { "type": "boolean", - "description": "Wait for domain reload completion before returning", + "description": "Wait for Domain Reload recovery after snippets that intentionally trigger Unity script reload or import work. Omit for normal inspection and editor-state workflows.", "default": false }, "YieldToForegroundRequests": { @@ -320,13 +320,13 @@ }, { "name": "control-play-mode", - "description": "Control Unity Editor play mode (play/stop/pause/step/status)", + "description": "Control Unity Editor Play Mode. Use to Play (or Resume, its alias), Stop, Pause, or Step Play Mode, or query Status without side effects, for runtime behavior checks and frame inspection.", "inputSchema": { "type": "object", "properties": { "Action": { "type": "string", - "description": "Action to perform: Play - Start play mode, Stop - Stop play mode, Pause - Pause play mode, Step - advance one frame while paused, Status - report current state without changing anything, Resume - alias of Play in every state, including starting Play Mode when stopped", + "description": "Play - start Play Mode, Stop - stop Play Mode, Pause - pause Play Mode, Step - advance one frame while paused, Status - report current state without changing anything, Resume - alias of Play in every state, including starting Play Mode when stopped", "enum": [ "Play", "Stop", @@ -385,7 +385,7 @@ }, "MaxPreviewElements": { "type": "integer", - "description": "Maximum number of elements to include in a captured collection's preview (1-1000)", + "description": "Maximum number of elements to include in a captured collection's preview (1-1000). The value set at enable time also caps the previews in every later pause-point-status response for that marker; status has no flag to change it.", "default": 10 } } @@ -462,13 +462,13 @@ }, { "name": "simulate-mouse-ui", - "description": "Simulate mouse click, long-press, and drag on PlayMode UI elements via EventSystem screen coordinates", + "description": "Simulate PlayMode EventSystem UI mouse actions using screen coordinates. Use for UI clicks, long-presses, or drags from annotated screenshots.", "inputSchema": { "type": "object", "properties": { "Action": { "type": "string", - "description": "Mouse action: Click - click at position, Drag - one-shot drag, DragStart - begin drag and hold, DragMove - move while holding drag, DragEnd - release drag, LongPress - press and hold for Duration seconds", + "description": "Click - click at position, Drag - one-shot drag, DragStart - begin drag and hold, DragMove - move while holding drag, DragEnd - release drag, LongPress - press and hold for --duration seconds", "enum": [ "Click", "Drag", @@ -491,17 +491,17 @@ }, "FromX": { "type": "number", - "description": "Start X position for Drag action (origin: top-left). Drag starts here and moves to X,Y.", + "description": "Start X position for Drag action (origin: top-left). Drag starts here and moves to --x,--y.", "default": 0 }, "FromY": { "type": "number", - "description": "Start Y position for Drag action (origin: top-left). Drag starts here and moves to X,Y.", + "description": "Start Y position for Drag action (origin: top-left). Drag starts here and moves to --x,--y.", "default": 0 }, "DragSpeed": { "type": "number", - "description": "Drag speed in pixels per second (0 for instant). Applies to Drag, DragMove, and DragEnd actions.", + "description": "Drag speed in pixels per second (0 for instant). 2000 is fast (default), 200 is slow enough to watch. Applies to Drag, DragMove, and DragEnd actions.", "default": 2000 }, "Duration": { @@ -511,7 +511,7 @@ }, "Button": { "type": "string", - "description": "Mouse button: Left (default), Right, Middle.", + "description": "Mouse button. Click and LongPress support Left, Right, and Middle. Drag actions support Left only; other buttons return an error.", "enum": [ "Left", "Right", @@ -521,17 +521,17 @@ }, "BypassRaycast": { "type": "boolean", - "description": "Bypass EventSystem raycast and send click, long-press, or drag events directly to TargetPath, or DropTargetPath for DragEnd. Useful for interacting with UI behind a raycast-blocking overlay.", + "description": "For Click, LongPress, Drag, and DragStart, bypass EventSystem raycast and dispatch pointer events directly to --target-path. Use when a raycast-blocking overlay visually covers the intended target.", "default": false }, "TargetPath": { "type": "string", - "description": "Hierarchy path of the target GameObject used by Click, LongPress, Drag, and DragStart when BypassRaycast is true, for example Canvas/Panel/Button.", + "description": "Hierarchy path of the target GameObject, for example Canvas/Panel/Button. Required when --bypass-raycast is used with Click, LongPress, Drag, or DragStart; prefer AnnotatedElements[].Path from screenshot JSON.", "default": "" }, "DropTargetPath": { "type": "string", - "description": "Optional hierarchy path of the drop target used by Drag and DragEnd, for example Canvas/DropZone.", + "description": "Optional hierarchy path of a drop target for Drag or DragEnd, for example Canvas/DropZone. Use this when the drop zone is also behind a raycast blocker.", "default": "" } } @@ -539,13 +539,13 @@ }, { "name": "simulate-mouse-input", - "description": "Simulate mouse input in PlayMode via Input System. Injects button clicks, mouse delta, and scroll wheel directly into Mouse.current for game logic that reads Input System. Requires the Input System package and Active Input Handling set to 'Input System Package (New)' or 'Both'.", + "description": "Simulate Mouse.current input in PlayMode through Unity Input System. Use for gameplay mouse clicks, long-press (LongPress), movement delta (MoveDelta/SmoothDelta), or scroll. Use simulate-mouse-ui for UI. Requires the Input System package and Active Input Handling set to 'Input System Package (New)' or 'Both'.", "inputSchema": { "type": "object", "properties": { "Action": { "type": "string", - "description": "Mouse input action: Click - inject button press+release, LongPress - inject button hold for Duration seconds, MoveDelta - inject mouse delta (one-shot), SmoothDelta - inject mouse delta smoothly over Duration seconds, Scroll - inject scroll wheel", + "description": "Click - inject button press+release, LongPress - inject button hold for --duration seconds, MoveDelta - inject mouse delta (one-shot), SmoothDelta - inject mouse delta smoothly over --duration seconds, Scroll - inject scroll wheel", "enum": [ "Click", "LongPress", @@ -567,7 +567,7 @@ }, "Button": { "type": "string", - "description": "Mouse button: Left (default), Right, Middle. Used by Click and LongPress.", + "description": "Mouse button: Left, Right, Middle. Used by Click and LongPress.", "enum": [ "Left", "Right", @@ -577,17 +577,17 @@ }, "Duration": { "type": "number", - "description": "Duration in seconds for LongPress hold, SmoothDelta interpolation, or minimum hold time for Click (0 = one-shot tap).", + "description": "Hold duration for LongPress, or interpolation duration for SmoothDelta (seconds). For Click, 0 = one-shot tap.", "default": 0 }, "DeltaX": { "type": "number", - "description": "Delta X in pixels for MoveDelta/SmoothDelta action. Positive = right.", + "description": "Delta X in pixels for MoveDelta/SmoothDelta. Positive = right.", "default": 0 }, "DeltaY": { "type": "number", - "description": "Delta Y in pixels for MoveDelta/SmoothDelta action. Positive = up.", + "description": "Delta Y in pixels for MoveDelta/SmoothDelta. Positive = up.", "default": 0 }, "ScrollX": { @@ -605,13 +605,13 @@ }, { "name": "simulate-keyboard", - "description": "Simulate keyboard key input in PlayMode via Input System. Supports one-shot press, key-down hold, key-up release, and ReleaseAll recovery for game controls (WASD, Space, etc.). Requires the Input System package (com.unity.inputsystem).", + "description": "Simulate keyboard input in PlayMode through Unity Input System. Use for key presses, holds (via Press --duration or KeyDown/KeyUp), releases, and game controls such as WASD or Space. Requires the Input System package (com.unity.inputsystem).", "inputSchema": { "type": "object", "properties": { "Action": { "type": "string", - "description": "Keyboard action: Press - one-shot key tap (Down then Up), KeyDown - hold key down, KeyUp - release held key, ReleaseAll - force-release every tracked and device-pressed key (allowed while PlayMode is paused; use after a pause-point interruption leaves key state inconsistent)", + "description": "Press - one-shot key tap (Down then Up), KeyDown - hold key down, KeyUp - release held key, ReleaseAll - force-release every tracked and device-pressed key (allowed while PlayMode is paused; use after a pause-point interruption leaves key state inconsistent)", "enum": [ "Press", "KeyDown", @@ -622,7 +622,7 @@ }, "Key": { "type": "string", - "description": "Key name matching Input System Key enum (e.g. \"W\", \"Space\", \"LeftShift\", \"A\", \"Return\"). Case-insensitive. Not required for ReleaseAll." + "description": "Key name matching Input System Key enum (e.g. W, Space, LeftShift, A, Enter). Case-insensitive. Digit keys use Digit0-Digit9 or Numpad0-Numpad9, not bare 0-9. Not used by ReleaseAll." }, "Duration": { "type": "number", @@ -634,7 +634,7 @@ }, { "name": "record-input", - "description": "Record keyboard and mouse input during PlayMode. Captures key presses, mouse clicks, mouse delta, and scroll events frame-by-frame. Saves to JSON for later replay.", + "description": "Record PlayMode keyboard and mouse input to JSON. Use to capture gameplay, bug repro, or E2E input sequences for replay.", "inputSchema": { "type": "object", "properties": { @@ -644,17 +644,17 @@ "Start", "Stop" ], - "description": "Recording action: Start - begin recording input, Stop - stop recording and save to file", + "description": "Start - begin recording input, Stop - stop recording and save to file", "default": "Start" }, "OutputPath": { "type": "string", - "description": "Output file path for the recording JSON. If empty, auto-generates under .uloop/outputs/InputRecordings/", + "description": "Save path for the recording JSON. When empty, auto-generates under .uloop/outputs/InputRecordings/", "default": "" }, "Keys": { "type": "string", - "description": "Comma-separated key filter. Only record specified keys (e.g. 'W,A,S,D,Space'). Empty means record all common game keys.", + "description": "Comma-separated key filter of Input System Key enum names (for example W,A,S,D,Space). Case-insensitive. Digit keys use Digit0-Digit9 or Numpad0-Numpad9, not bare 0-9; a name that matches no key fails the command instead of being dropped from the filter. Empty records all common game keys", "default": "" }, "DelaySeconds": { @@ -664,7 +664,7 @@ }, "ShowOverlay": { "type": "boolean", - "description": "Show recording overlay (countdown + REC indicator)", + "description": "Hide the recording countdown and REC indicator overlay", "default": true } } @@ -672,7 +672,7 @@ }, { "name": "replay-input", - "description": "Replay recorded keyboard and mouse input during PlayMode. Injects recorded events frame-by-frame via Input System to reproduce exact input sequences.", + "description": "Replay recorded PlayMode keyboard and mouse input. Use for exact gameplay reproduction, E2E runs, or consistent demos from JSON recordings.", "inputSchema": { "type": "object", "properties": { @@ -683,22 +683,22 @@ "Stop", "Status" ], - "description": "Replay action: Start - begin replaying, Stop - stop mid-way, Status - check progress", + "description": "Start - begin replaying, Stop - stop mid-way, Status - check progress", "default": "Start" }, "InputPath": { "type": "string", - "description": "Path to recording JSON file. If empty, auto-detects the latest recording in .uloop/outputs/InputRecordings/", + "description": "Path to the recording JSON. When empty, auto-detects the latest recording in .uloop/outputs/InputRecordings/", "default": "" }, "ShowOverlay": { "type": "boolean", - "description": "Show visualization overlay during replay", + "description": "Hide replay progress overlay", "default": true }, "Loop": { "type": "boolean", - "description": "Loop replay continuously", + "description": "Loop continuously", "default": false } } @@ -706,28 +706,28 @@ }, { "name": "raycast", - "description": "Raycast from Camera.main through a top-left Game View coordinate", + "description": "Raycast from Camera.main through a Game View coordinate. Use when you need to check what a screenshot coordinate would hit in 3D physics before clicking or long-pressing with simulate-mouse-ui.", "inputSchema": { "type": "object", "properties": { "X": { "type": "number", - "description": "Target X position in Game View pixels (origin: top-left)", + "description": "Target X position in Game View pixels (origin: top-left).", "default": 0 }, "Y": { "type": "number", - "description": "Target Y position in Game View pixels (origin: top-left)", + "description": "Target Y position in Game View pixels (origin: top-left).", "default": 0 }, "LayerMask": { "type": "integer", - "description": "Physics layer mask used by the raycast", + "description": "Physics layer mask used by the raycast.", "default": -5 }, "MaxDistance": { "type": "number", - "description": "Maximum raycast distance in world units", + "description": "Maximum raycast distance in world units.", "default": 1000 } } @@ -741,11 +741,11 @@ "properties": { "Width": { "type": "integer", - "description": "Target Game View rendering width in pixels. Provide with Height to change the resolution." + "description": "Target Game View rendering width in pixels. Provide with --height to change the resolution." }, "Height": { "type": "integer", - "description": "Target Game View rendering height in pixels. Provide with Width to change the resolution." + "description": "Target Game View rendering height in pixels. Provide with --width to change the resolution." } } } diff --git a/cli/common/tools/description_fallback.go b/cli/common/tools/description_fallback.go new file mode 100644 index 0000000000..befd100e63 --- /dev/null +++ b/cli/common/tools/description_fallback.go @@ -0,0 +1,60 @@ +package tools + +// unityPlaceholderDescriptionPrefix is what Unity's schema generator emits for a property that +// carries no [Description] attribute (UnityCliLoopToolParameterSchemaGenerator.GetDescription +// returns "Parameter: "). The generated schema cache is therefore almost entirely +// placeholders, and the cache has no tool-level description field at all. +const unityPlaceholderDescriptionPrefix = "Parameter: " + +// ApplyEmbeddedDescriptionFallback fills in the descriptions a synced project cache does not carry, +// using the catalog embedded in this binary. Without it, every option of every tool reads +// "Parameter: " inside a synced project while the same command outside a project shows real +// help — the cache was strictly worse than having no cache. +// +// Only description text is taken from the embedded catalog. Type, enum, default, required, and +// hidden always stay as the cache reported them, because Unity is the authority on what the running +// Editor actually accepts. The embedded text may come from an older or newer generation than the +// installed package, which is why it is a fallback and not a replacement. +func ApplyEmbeddedDescriptionFallback(catalog ToolCatalog) ToolCatalog { + embedded := LoadDefault() + + for index, tool := range catalog.Tools { + embeddedTool, ok := Find(embedded, tool.Name) + if !ok { + // A tool the embedded catalog does not know is a project-local custom command: its + // author's own [Description] text is all there is, so it passes through untouched. + continue + } + + if tool.Description == "" { + tool.Description = embeddedTool.Description + } + fillPlaceholderPropertyDescriptions(tool.EffectiveInputSchema(), embeddedTool.EffectiveInputSchema()) + catalog.Tools[index] = tool + } + + return catalog +} + +func fillPlaceholderPropertyDescriptions(schema ToolInputSchema, embeddedSchema ToolInputSchema) { + for propertyName, property := range schema.Properties { + if !isPlaceholderDescription(property.Description, propertyName) { + // A real description means the schema author wrote one; overwriting it would discard + // the more specific text in favor of this binary's generation. + continue + } + + embeddedProperty, ok := embeddedSchema.Properties[propertyName] + if !ok || isPlaceholderDescription(embeddedProperty.Description, propertyName) { + continue + } + + property.Description = embeddedProperty.Description + property.SkillSourcedDescription = embeddedProperty.SkillSourcedDescription + schema.Properties[propertyName] = property + } +} + +func isPlaceholderDescription(description string, propertyName string) bool { + return description == "" || description == unityPlaceholderDescriptionPrefix+propertyName +} diff --git a/cli/common/tools/description_fallback_test.go b/cli/common/tools/description_fallback_test.go new file mode 100644 index 0000000000..4ad53be91f --- /dev/null +++ b/cli/common/tools/description_fallback_test.go @@ -0,0 +1,208 @@ +package tools + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// Verifies a cached property whose description is Unity's generated placeholder is replaced with the +// embedded catalog's real description, since the placeholder carries no information at all. +func TestApplyEmbeddedDescriptionFallbackReplacesPlaceholders(t *testing.T) { + catalog := ToolCatalog{Tools: []ToolDefinition{{ + Name: "simulate-keyboard", + ParameterSchema: ToolInputSchema{Properties: map[string]ToolProperty{ + "Duration": {Type: "number", Description: "Parameter: Duration"}, + }}, + }}} + + result := ApplyEmbeddedDescriptionFallback(catalog) + + description := result.Tools[0].EffectiveInputSchema().Properties["Duration"].Description + if description == "Parameter: Duration" || description == "" { + t.Fatalf("placeholder description was not replaced: %q", description) + } +} + +// Verifies an empty description is treated as a placeholder too, since it is equally uninformative. +func TestApplyEmbeddedDescriptionFallbackReplacesEmptyDescriptions(t *testing.T) { + catalog := ToolCatalog{Tools: []ToolDefinition{{ + Name: "simulate-keyboard", + ParameterSchema: ToolInputSchema{Properties: map[string]ToolProperty{ + "Duration": {Type: "number", Description: ""}, + }}, + }}} + + result := ApplyEmbeddedDescriptionFallback(catalog) + + if result.Tools[0].EffectiveInputSchema().Properties["Duration"].Description == "" { + t.Fatal("empty description was not replaced") + } +} + +// Verifies a description the schema author actually wrote is never overwritten: the cache is +// authoritative whenever it carries real information. +func TestApplyEmbeddedDescriptionFallbackKeepsAuthoredDescriptions(t *testing.T) { + catalog := ToolCatalog{Tools: []ToolDefinition{{ + Name: "simulate-keyboard", + Description: "Cached tool description", + ParameterSchema: ToolInputSchema{Properties: map[string]ToolProperty{ + "Duration": {Type: "number", Description: "Authored duration description"}, + }}, + }}} + + result := ApplyEmbeddedDescriptionFallback(catalog) + + if result.Tools[0].Description != "Cached tool description" { + t.Errorf("tool description was overwritten: %q", result.Tools[0].Description) + } + if got := result.Tools[0].EffectiveInputSchema().Properties["Duration"].Description; got != "Authored duration description" { + t.Errorf("authored property description was overwritten: %q", got) + } +} + +// Verifies a tool absent from the embedded catalog passes through untouched, so a project's custom +// commands are not silently emptied or matched against an unrelated tool. +func TestApplyEmbeddedDescriptionFallbackLeavesCustomToolsAlone(t *testing.T) { + catalog := ToolCatalog{Tools: []ToolDefinition{{ + Name: "my-custom-command", + ParameterSchema: ToolInputSchema{Properties: map[string]ToolProperty{ + "Value": {Type: "string", Description: "Parameter: Value"}, + }}, + }}} + + result := ApplyEmbeddedDescriptionFallback(catalog) + + if result.Tools[0].Description != "" { + t.Errorf("custom tool gained a description: %q", result.Tools[0].Description) + } + if got := result.Tools[0].EffectiveInputSchema().Properties["Value"].Description; got != "Parameter: Value" { + t.Errorf("custom tool property description changed: %q", got) + } +} + +// Verifies a property the embedded catalog does not know keeps its placeholder rather than picking +// up an unrelated description. +func TestApplyEmbeddedDescriptionFallbackKeepsUnknownProperties(t *testing.T) { + catalog := ToolCatalog{Tools: []ToolDefinition{{ + Name: "simulate-keyboard", + ParameterSchema: ToolInputSchema{Properties: map[string]ToolProperty{ + "NewlyAddedOption": {Type: "string", Description: "Parameter: NewlyAddedOption"}, + }}, + }}} + + result := ApplyEmbeddedDescriptionFallback(catalog) + + if got := result.Tools[0].EffectiveInputSchema().Properties["NewlyAddedOption"].Description; got != "Parameter: NewlyAddedOption" { + t.Errorf("unknown property description changed: %q", got) + } +} + +// Verifies enum, default, type, and required stay as the cache reported them: Unity is the truth for +// everything except the description text. +func TestApplyEmbeddedDescriptionFallbackKeepsCachedSchemaFacts(t *testing.T) { + catalog := ToolCatalog{Tools: []ToolDefinition{{ + Name: "simulate-keyboard", + ParameterSchema: ToolInputSchema{ + Properties: map[string]ToolProperty{ + "Action": { + Type: "string", + Description: "Parameter: Action", + DefaultValue: float64(0), + Enum: []string{"Press", "OnlyCachedMember"}, + }, + }, + Required: []string{"Action"}, + }, + }}} + + result := ApplyEmbeddedDescriptionFallback(catalog) + + property := result.Tools[0].EffectiveInputSchema().Properties["Action"] + if len(property.Enum) != 2 || property.Enum[1] != "OnlyCachedMember" { + t.Errorf("cached enum was replaced: %v", property.Enum) + } + if property.EffectiveDefault() != float64(0) { + t.Errorf("cached default was replaced: %v", property.EffectiveDefault()) + } + if required := result.Tools[0].EffectiveInputSchema().Required; len(required) != 1 || required[0] != "Action" { + t.Errorf("cached required list was replaced: %v", required) + } +} + +// Verifies the fallback is applied when a project cache is loaded, which is the path both `--help` +// and `uloop list` read in a synced project. +func TestLoadProjectCacheAppliesDescriptionFallback(t *testing.T) { + projectRoot := t.TempDir() + cacheDirectory := filepath.Join(projectRoot, CacheDirectoryName) + if err := os.MkdirAll(cacheDirectory, 0o755); err != nil { + t.Fatalf("failed to create cache directory: %v", err) + } + content := `{"Tools":[{"name":"simulate-keyboard","parameterSchema":{"Properties":{"Duration":{"Type":"number","Description":"Parameter: Duration"}}}}]}` + if err := os.WriteFile(filepath.Join(cacheDirectory, CacheFileName), []byte(content), 0o644); err != nil { + t.Fatalf("failed to write cache: %v", err) + } + + catalog, ok := LoadProjectCache(projectRoot, nil) + if !ok { + t.Fatal("project cache was not loaded") + } + if catalog.Tools[0].Description == "" { + t.Error("tool description was not filled from the embedded catalog") + } + if got := catalog.Tools[0].EffectiveInputSchema().Properties["Duration"].Description; got == "Parameter: Duration" { + t.Errorf("property placeholder was not replaced: %q", got) + } +} + +// Verifies the embedded catalog itself has no placeholder descriptions, since it is the source the +// fallback reads from. +func TestEmbeddedCatalogHasNoPlaceholderDescriptions(t *testing.T) { + for _, tool := range LoadDefault().Tools { + if tool.Description == "" { + t.Errorf("embedded tool %s has no description", tool.Name) + } + for propertyName, property := range tool.EffectiveInputSchema().Properties { + if isPlaceholderDescription(property.Description, propertyName) { + t.Errorf("embedded tool %s property %s has a placeholder description", tool.Name, propertyName) + } + } + } +} + +// Verifies the documented digit-key rule reaches simulate-keyboard's --key description, so a caller +// reading only `--help` learns that bare digits are rejected. +func TestEmbeddedSimulateKeyboardKeyDescriptionDocumentsDigitKeys(t *testing.T) { + tool, ok := Find(LoadDefault(), "simulate-keyboard") + if !ok { + t.Fatal("embedded catalog has no simulate-keyboard tool") + } + + // The text is generated from the skill's parameter table, which states the rule as the accepted + // ranges rather than one example digit; the guarantee this pins - a caller learns bare digits are + // rejected - is unchanged. + description := tool.EffectiveInputSchema().Properties["Key"].Description + for _, expected := range []string{"Digit0-Digit9", "Numpad0-Numpad9", "not bare 0-9"} { + if !strings.Contains(description, expected) { + t.Errorf("--key description does not mention %q: %q", expected, description) + } + } + if strings.Contains(description, "\"Return\"") { + t.Errorf("--key description still offers the rejected Return example: %q", description) + } +} + +// Verifies enable-pause-point documents that --max-preview-elements also shapes later +// pause-point-status responses, which is not discoverable from the option name. +func TestEmbeddedEnablePausePointDocumentsMaxPreviewElementsCarryOver(t *testing.T) { + tool, ok := Find(LoadDefault(), "enable-pause-point") + if !ok { + t.Fatal("embedded catalog has no enable-pause-point tool") + } + + description := tool.EffectiveInputSchema().Properties["MaxPreviewElements"].Description + if !strings.Contains(description, "pause-point-status") { + t.Errorf("--max-preview-elements description does not mention pause-point-status: %q", description) + } +} diff --git a/cli/common/tools/types.go b/cli/common/tools/types.go index 82af349a6f..028c3eb025 100644 --- a/cli/common/tools/types.go +++ b/cli/common/tools/types.go @@ -20,13 +20,20 @@ type ToolInputSchema struct { } type ToolProperty struct { - Type string `json:"type"` - Description string `json:"description,omitempty"` - Default any `json:"default,omitempty"` - DefaultValue any `json:"defaultValue,omitempty"` - Hidden bool `json:"hidden,omitempty"` - Enum []string `json:"enum,omitempty"` - Items *struct { + Type string `json:"type"` + Description string `json:"description,omitempty"` + Default any `json:"default,omitempty"` + DefaultValue any `json:"defaultValue,omitempty"` + Hidden bool `json:"hidden,omitempty"` + // SkillSourcedDescription marks a description that came from a skill parameter table, either read + // live from the installed package or through the embedded catalog, which is generated from those + // same tables. Help renders such text verbatim; a description with no skill behind it belongs to a + // project-local custom command, whose author wrote it in the positive sense and which therefore + // still needs a synthesized summary for a negated boolean flag. Never serialized: provenance is a + // property of how this process loaded the catalog, not of the file. + SkillSourcedDescription bool `json:"-"` + Enum []string `json:"enum,omitempty"` + Items *struct { Type string `json:"type"` } `json:"items,omitempty"` } diff --git a/cli/dispatcher/internal/dispatcher/command_help.go b/cli/dispatcher/internal/dispatcher/command_help.go index 8d6367b938..f0505b03af 100644 --- a/cli/dispatcher/internal/dispatcher/command_help.go +++ b/cli/dispatcher/internal/dispatcher/command_help.go @@ -5,6 +5,7 @@ import ( "sort" clierrors "github.com/hatayama/unity-cli-loop/common/errors" + "github.com/hatayama/unity-cli-loop/common/skilldocs" "github.com/hatayama/unity-cli-loop/common/tooldocs" "github.com/hatayama/unity-cli-loop/common/clicore" @@ -25,7 +26,9 @@ func tryHandleCommandHelp(command string, startPath string, projectPath string, if err != nil { if projectPath == "" { if tool, ok := clicore.FindDefaultTool(command); ok { - printToolHelp(tool, stdout) + // No project resolved, so no installed package to read skills from: this path + // renders from the catalog embedded in this binary, as it always has. + printToolHelp(tool, "", stdout) return true, 0 } } @@ -45,7 +48,7 @@ func tryHandleCommandHelp(command string, startPath string, projectPath string, return true, 1 } - printToolHelp(tool, stdout) + printToolHelp(tool, connection.ProjectRoot, stdout) return true, 0 } @@ -67,6 +70,7 @@ func printNativeSingleCommandHelp(command string, stdout io.Writer) { clicore.WriteLine(stdout, "") printGlobalOptionsHelp(stdout) } + printSkillGuidanceHelp(command, stdout) return } @@ -79,9 +83,15 @@ func printNativeSingleCommandHelp(command string, stdout io.Writer) { clicore.WriteLine(stdout, "") printGlobalOptionsHelp(stdout) } + printSkillGuidanceHelp(command, stdout) } -func printToolHelp(tool clicore.ToolDefinition, stdout io.Writer) { +// printToolHelp renders one Unity tool command's help. Descriptions come from the installed +// package's SKILL.md tables when a project is resolved, so the help and the skill an agent reads +// cannot disagree; without a project root the embedded catalog is used unchanged. +func printToolHelp(tool clicore.ToolDefinition, projectRoot string, stdout io.Writer) { + tool = skilldocs.ApplyToTool(tool, projectRoot) + clicore.WriteLine(stdout, "Usage:") clicore.WriteFormat(stdout, " uloop %s", tool.Name) if len(tooldocs.VisibleOptionHelpEntriesForTool(tool)) > 0 { @@ -100,12 +110,27 @@ func printToolHelp(tool clicore.ToolDefinition, stdout io.Writer) { clicore.WriteLine(stdout, "") clicore.WriteLine(stdout, "Options:") for _, entry := range entries { - clicore.WriteFormat(stdout, " %-32s %s\n", entry.Usage, entry.Description) + // Wide enough for the longest usage string (--captured-variable-names ), so no + // single row pushes its description out of the column. + clicore.WriteFormat(stdout, " %-34s %s\n", entry.Usage, entry.Description) } } clicore.WriteLine(stdout, "") printGlobalOptionsHelp(stdout) + printSkillGuidanceHelp(tool.Name, stdout) +} + +// printSkillGuidanceHelp closes a command's help with the instruction to load its skill. Nothing is +// printed for a command with no skill (custom commands), so the output never names a skill that +// cannot be loaded. +func printSkillGuidanceHelp(command string, stdout io.Writer) { + guidance, ok := tooldocs.SkillGuidanceLine(command) + if !ok { + return + } + clicore.WriteLine(stdout, "") + clicore.WriteLine(stdout, guidance) } func nativeCommandDescription(command string) (string, bool) { diff --git a/cli/dispatcher/internal/dispatcher/command_help_skill_docs_test.go b/cli/dispatcher/internal/dispatcher/command_help_skill_docs_test.go new file mode 100644 index 0000000000..b50a66fcb2 --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/command_help_skill_docs_test.go @@ -0,0 +1,175 @@ +package dispatcher + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +const skillDocsFixtureDurationDescription = "Hold duration written only in the fixture skill." + +const skillDocsFixtureIncludeMaterialsDescription = "Leave material information out of the dump." + +// Verifies option and tool help text come from the installed package's SKILL.md table rather than +// from the descriptions compiled into this binary, which is the drift this reader removes. +func TestCommandHelpReadsDescriptionsFromTheInstalledSkill(t *testing.T) { + projectRoot := createLaunchTestProject(t) + writeSkillDocsFixturePackage(t, projectRoot, skillDocsFixtureDurationDescription) + writeToolCache(t, projectRoot, `{ + "tools": [ + { + "name": "simulate-keyboard", + "inputSchema": { + "type": "object", + "properties": { + "Duration": {"type": "number", "description": "Parameter: Duration"} + } + } + } + ] +}`) + var stdout bytes.Buffer + var stderr bytes.Buffer + + handled, code := tryHandleCommandHelp("simulate-keyboard", projectRoot, projectRoot, &stdout, &stderr) + + if !handled || code != 0 { + t.Fatalf("simulate-keyboard help was not handled: handled=%v code=%d stderr=%s", handled, code, stderr.String()) + } + output := stdout.String() + if !strings.Contains(output, skillDocsFixtureDurationDescription) { + t.Errorf("the option description was not read from the skill:\n%s", output) + } + if !strings.Contains(output, "Simulate keyboard input from the fixture skill.") { + t.Errorf("the tool description was not read from the skill:\n%s", output) + } +} + +// Verifies a project with no installed package still prints full help from the embedded catalog: a +// missing or unreadable skill may only cost freshness, never the help itself. +func TestCommandHelpKeepsEmbeddedDescriptionsWithoutAnInstalledSkill(t *testing.T) { + projectRoot := createLaunchTestProject(t) + writeToolCache(t, projectRoot, `{ + "tools": [ + { + "name": "simulate-keyboard", + "inputSchema": { + "type": "object", + "properties": { + "Duration": {"type": "number", "description": "Parameter: Duration"} + } + } + } + ] +}`) + var stdout bytes.Buffer + var stderr bytes.Buffer + + handled, code := tryHandleCommandHelp("simulate-keyboard", projectRoot, projectRoot, &stdout, &stderr) + + if !handled || code != 0 { + t.Fatalf("simulate-keyboard help was not handled: handled=%v code=%d stderr=%s", handled, code, stderr.String()) + } + output := stdout.String() + if !strings.Contains(output, "--duration") { + t.Fatalf("the option was not listed at all:\n%s", output) + } + if strings.Contains(output, "Parameter: Duration") { + t.Errorf("the placeholder description survived instead of the embedded text:\n%s", output) + } +} + +// Verifies a negated boolean's help text comes from the skill table verbatim rather than the +// synthesized "Disable ". The synthesis is correct only for a description with no skill behind +// it, so the reader has to mark the descriptions it supplied as skill-sourced. +func TestCommandHelpPrintsASkillSourcedNegatedBooleanVerbatim(t *testing.T) { + projectRoot := createLaunchTestProject(t) + writeNegatedBooleanSkillFixture(t, projectRoot) + writeToolCache(t, projectRoot, `{ + "tools": [ + { + "name": "get-hierarchy", + "inputSchema": { + "type": "object", + "properties": { + "IncludeMaterials": {"type": "boolean", "default": true, "description": "Parameter: IncludeMaterials"} + } + } + } + ] +}`) + var stdout bytes.Buffer + var stderr bytes.Buffer + + handled, code := tryHandleCommandHelp("get-hierarchy", projectRoot, projectRoot, &stdout, &stderr) + + if !handled || code != 0 { + t.Fatalf("get-hierarchy help was not handled: handled=%v code=%d stderr=%s", handled, code, stderr.String()) + } + output := stdout.String() + if !strings.Contains(output, skillDocsFixtureIncludeMaterialsDescription) { + t.Errorf("the skill's flag wording was not printed:\n%s", output) + } + if strings.Contains(output, "Disable include materials") { + t.Errorf("the synthesized summary replaced the skill's wording:\n%s", output) + } +} + +// writeNegatedBooleanSkillFixture installs a package whose get-hierarchy skill documents the +// --no-include-materials flag from the flag's point of view. The property is deliberately one the +// embedded catalog does not know, so only the skill can supply its description. +func writeNegatedBooleanSkillFixture(t *testing.T, projectRoot string) { + t.Helper() + + packageRoot := filepath.Join(projectRoot, "Packages", "src") + skillDirectory := filepath.Join(packageRoot, "Editor", "FirstPartyTools", "GetHierarchy", "Skill") + if err := os.MkdirAll(skillDirectory, 0o755); err != nil { + t.Fatalf("failed to create the skill directory: %v", err) + } + manifest := []byte(`{"name":"io.github.hatayama.uloopmcp"}`) + if err := os.WriteFile(filepath.Join(packageRoot, "package.json"), manifest, 0o644); err != nil { + t.Fatalf("failed to write the package manifest: %v", err) + } + + skill := "---\n" + + "name: uloop-get-hierarchy\n" + + "toolName: get-hierarchy\n" + + "description: \"Dump the scene hierarchy from the fixture skill.\"\n" + + "---\n\n" + + "| Parameter | Type | Default | Description |\n" + + "|-----------|------|---------|-------------|\n" + + "| `--no-include-materials` | flag | - | " + skillDocsFixtureIncludeMaterialsDescription + " |\n" + if err := os.WriteFile(filepath.Join(skillDirectory, "SKILL.md"), []byte(skill), 0o644); err != nil { + t.Fatalf("failed to write the fixture skill: %v", err) + } +} + +// writeSkillDocsFixturePackage installs a uloop package inside the project whose simulate-keyboard +// skill documents --duration with the given text. +func writeSkillDocsFixturePackage(t *testing.T, projectRoot string, durationDescription string) { + t.Helper() + + packageRoot := filepath.Join(projectRoot, "Packages", "src") + skillDirectory := filepath.Join(packageRoot, "Editor", "FirstPartyTools", "SimulateKeyboard", "Skill") + if err := os.MkdirAll(skillDirectory, 0o755); err != nil { + t.Fatalf("failed to create the skill directory: %v", err) + } + manifest := []byte(`{"name":"io.github.hatayama.uloopmcp"}`) + if err := os.WriteFile(filepath.Join(packageRoot, "package.json"), manifest, 0o644); err != nil { + t.Fatalf("failed to write the package manifest: %v", err) + } + + skill := "---\n" + + "name: uloop-simulate-keyboard\n" + + "toolName: simulate-keyboard\n" + + "description: \"Simulate keyboard input from the fixture skill.\"\n" + + "---\n\n" + + "| Parameter | Type | Default | Description |\n" + + "|-----------|------|---------|-------------|\n" + + "| `--duration` | number | `0` | " + durationDescription + " |\n" + if err := os.WriteFile(filepath.Join(skillDirectory, "SKILL.md"), []byte(skill), 0o644); err != nil { + t.Fatalf("failed to write the fixture skill: %v", err) + } +} diff --git a/cli/dispatcher/internal/dispatcher/help_test.go b/cli/dispatcher/internal/dispatcher/help_test.go index 70108404cc..45e340e7ff 100644 --- a/cli/dispatcher/internal/dispatcher/help_test.go +++ b/cli/dispatcher/internal/dispatcher/help_test.go @@ -204,7 +204,7 @@ func TestRunDispatcherCompileHelpDoesNotRequireUnityProject(t *testing.T) { "--force-recompile", "--no-wait-for-domain-reload", "--stop-on-external-scene-changes", - "Stop before execution if open Scene files changed externally instead of auto-reloading them", + "Stop before compilation if open Scene files changed externally instead of auto-reloading them", "default: auto-reload enabled", } { if !strings.Contains(output, expected) { @@ -253,6 +253,106 @@ func TestCommandHelpPrefersProjectCacheForDefaultToolNames(t *testing.T) { } } +// Verifies a tool's help closes with the instruction to load its skill, which is the only pointer +// from --help to the workflow rules and response shapes that --help itself cannot carry. +func TestCommandHelpPointsAtTheToolSkill(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + handled, code := tryHandleCommandHelp("simulate-keyboard", "", "", &stdout, &stderr) + + if !handled || code != 0 { + t.Fatalf("simulate-keyboard help was not handled: handled=%v code=%d stderr=%s", handled, code, stderr.String()) + } + if !strings.Contains(stdout.String(), "Load the uloop-simulate-keyboard skill") { + t.Fatalf("simulate-keyboard help does not point at its skill:\n%s", stdout.String()) + } +} + +// Verifies the watch commands point at the pause-point skill, which is where watch expressions are +// documented, rather than at a per-command skill that does not exist. +func TestCommandHelpPointsWatchCommandsAtThePausePointSkill(t *testing.T) { + for _, command := range []string{"enable-watch", "clear-watch", "get-watch-values"} { + t.Run(command, func(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + handled, code := tryHandleCommandHelp(command, "", "", &stdout, &stderr) + + if !handled || code != 0 { + t.Fatalf("%s help was not handled: handled=%v code=%d stderr=%s", command, handled, code, stderr.String()) + } + if !strings.Contains(stdout.String(), "Load the uloop-pause-point skill") { + t.Fatalf("%s help does not point at the pause-point skill:\n%s", command, stdout.String()) + } + }) + } +} + +// Verifies dispatcher-owned native commands get the guidance line too: launch renders its own help, +// so it would otherwise be the only command with a skill that never mentions it. +func TestLaunchHelpPointsAtTheLaunchSkill(t *testing.T) { + t.Chdir(t.TempDir()) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := RunDispatcher(context.Background(), []string{clicore.LaunchCommandName, "--help"}, &stdout, &stderr) + + if code != 0 { + t.Fatalf("launch help failed: code=%d stderr=%s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "Load the uloop-launch skill") { + t.Fatalf("launch help does not point at its skill:\n%s", stdout.String()) + } +} + +// Verifies a native command whose skill is internal-only (or absent) gets no guidance line. +func TestVersionHelpOmitsSkillLine(t *testing.T) { + t.Chdir(t.TempDir()) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := RunDispatcher(context.Background(), []string{clicore.VersionCommandName, "--help"}, &stdout, &stderr) + + if code != 0 { + t.Fatalf("version help failed: code=%d stderr=%s", code, stderr.String()) + } + if strings.Contains(stdout.String(), "skill") { + t.Fatalf("version help mentions a skill:\n%s", stdout.String()) + } +} + +// Verifies a command with no matching skill gets no guidance line, so a custom command is never +// told to load a skill nobody installed. +func TestCommandHelpOmitsSkillLineForCustomCommands(t *testing.T) { + projectRoot := createLaunchTestProject(t) + writeToolCache(t, projectRoot, `{ + "tools": [ + { + "name": "my-custom-command", + "description": "A project-local custom command", + "inputSchema": { + "type": "object", + "properties": { + "Value": {"type": "string", "description": "Some value"} + } + } + } + ] +}`) + var stdout bytes.Buffer + var stderr bytes.Buffer + + handled, code := tryHandleCommandHelp("my-custom-command", projectRoot, projectRoot, &stdout, &stderr) + + if !handled || code != 0 { + t.Fatalf("custom command help was not handled: handled=%v code=%d stderr=%s", handled, code, stderr.String()) + } + if strings.Contains(stdout.String(), "skill") { + t.Fatalf("custom command help mentions a skill:\n%s", stdout.String()) + } +} + // Verifies enable-watch stays a plain default tool and still exposes schema-driven option help. func TestCommandHelpUsesWatchToolSchemaForDefaultWatchCommands(t *testing.T) { var stdout bytes.Buffer @@ -342,7 +442,7 @@ func TestRunDispatcherRunTestsHelpDoesNotRequireUnityProject(t *testing.T) { "--filter-type", "--filter-value", "--fail-on-unsaved-changes", - "Fail before execution if unsaved editor changes remain instead of auto-saving them", + "Fail before test execution if unsaved editor changes remain instead of auto-saving them", "default: auto-save enabled", } { if !strings.Contains(output, expected) { diff --git a/cli/dispatcher/internal/dispatcher/launch.go b/cli/dispatcher/internal/dispatcher/launch.go index b024e8b214..ea2d938abb 100644 --- a/cli/dispatcher/internal/dispatcher/launch.go +++ b/cli/dispatcher/internal/dispatcher/launch.go @@ -473,4 +473,7 @@ func printLaunchHelp(stdout io.Writer) { clicore.WriteLine(stdout, "Compiler errors are ignored by default during Unity startup.") clicore.WriteLine(stdout, "") printGlobalOptionsHelp(stdout) + // launch renders its own help instead of going through printNativeSingleCommandHelp, so the + // closing skill line has to be printed here as well. + printSkillGuidanceHelp(clicore.LaunchCommandName, stdout) } diff --git a/cli/dispatcher/internal/dispatcher/run_help.go b/cli/dispatcher/internal/dispatcher/run_help.go index bba2632150..cee38be47b 100644 --- a/cli/dispatcher/internal/dispatcher/run_help.go +++ b/cli/dispatcher/internal/dispatcher/run_help.go @@ -5,6 +5,7 @@ import ( "os" "strings" + "github.com/hatayama/unity-cli-loop/common/skilldocs" "github.com/hatayama/unity-cli-loop/common/tooldocs" "github.com/hatayama/unity-cli-loop/common/clicontract" @@ -40,6 +41,9 @@ func printHelpForResolvedProject(stdout io.Writer, explicitProjectPath string) { } cache, ok := clicore.LoadProjectToolCache(connection.ProjectRoot) + // The command list prints one description per tool, so it reads the installed package's skills + // for the same reason single-command help does. + cache = skilldocs.ApplyToCatalog(cache, connection.ProjectRoot) printMainHelp(stdout, clicontract.ProjectRunnerVersion(), nativeCLIDescription, cache, ok) } diff --git a/cli/dispatcher/shared-inputs-stamp.json b/cli/dispatcher/shared-inputs-stamp.json index ff8ce993af..5750f15ebe 100644 --- a/cli/dispatcher/shared-inputs-stamp.json +++ b/cli/dispatcher/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "417686b615796ec8240c7bb104c4abd43c76dceb" + "sharedInputsHash": "0313515f1add4e1c394e1957c0aff7215e39e678" } diff --git a/cli/project-runner/internal/projectrunner/connection_retry.go b/cli/project-runner/internal/projectrunner/connection_retry.go index 6c163230b9..bb09179d8e 100644 --- a/cli/project-runner/internal/projectrunner/connection_retry.go +++ b/cli/project-runner/internal/projectrunner/connection_retry.go @@ -258,9 +258,15 @@ func sendWithTransientConnectionRetryWithDeps( ) } + // A caller that cancelled the command gets that cancellation back, as everywhere else that + // waits on Unity. Why here: the probe below inherits the cancellation and fails, and its + // failure would otherwise be reported as an unreachable Unity — telling the user to launch + // an editor they never asked about, and recording a probe warning for their own Ctrl-C. + if ctx.Err() != nil { + return outcome, ctx.Err() + } runningProcess, processErr := deps.findRunningUnityProcess(retryContext, connection.ProjectRoot) if finished, finalOutcome, finalErr := finishUndispatchedRetryProbe( - ctx, retryContext, connection, sendAttempt{ @@ -358,7 +364,13 @@ func shouldRetryUndispatchedConnection(err error, outcome unityipc.UnitySendOutc } var connectionErr *unityipc.ConnectionAttemptError - return errors.As(err, &connectionErr) + if !errors.As(err, &connectionErr) { + return false + } + // The retry window exists for a server that is not listening yet. A connect the kernel + // refused permanently never becomes reachable inside it, and retrying it replaces the + // syscall error with the window's own deadline expiry. + return !clierrors.IsPermanentConnectError(connectionErr) } func logConnectionRetryFocusAttempt( diff --git a/cli/project-runner/internal/projectrunner/connection_retry_flow.go b/cli/project-runner/internal/projectrunner/connection_retry_flow.go index 411ec0fae2..cfdeb61dcb 100644 --- a/cli/project-runner/internal/projectrunner/connection_retry_flow.go +++ b/cli/project-runner/internal/projectrunner/connection_retry_flow.go @@ -10,6 +10,7 @@ import ( "github.com/hatayama/unity-cli-loop/common/clicore" "github.com/hatayama/unity-cli-loop/common/unityipc" "github.com/hatayama/unity-cli-loop/common/unityprocess" + "github.com/hatayama/unity-cli-loop/common/vibelog" ) func newConnectionRetryClient( @@ -93,8 +94,13 @@ type sendAttempt struct { err error } +// finishUndispatchedRetryProbe decides whether the retry loop keeps waiting after a dial that +// never reached Unity. The process probe's only job here is to promote the diagnosis from "not +// reachable" to "running but not responding", so a probe that failed cannot promote anything: it +// observed no process, and claiming one would be an assertion this code has no evidence for. +// Both probe outcomes therefore report the dial error the caller can act on, and the probe +// failure goes to the CLI vibe log instead of replacing that error. func finishUndispatchedRetryProbe( - ctx context.Context, retryContext context.Context, connection unityipc.Connection, currentAttempt sendAttempt, @@ -103,31 +109,37 @@ func finishUndispatchedRetryProbe( lastAttempt sendAttempt, ) (bool, unityipc.UnitySendOutcome, error) { if processErr != nil { - if retryContext.Err() == nil { - return true, currentAttempt.outcome, processErr - } - if ctx.Err() != nil { - return true, currentAttempt.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(lastAttempt.err) { - return true, lastAttempt.outcome, lastAttempt.err - } - return true, currentAttempt.outcome, newUnityServerNotRespondingError(connection, currentAttempt.err) + logUnityProcessProbeFailure(connection, currentAttempt.err, processErr) } if runningProcess != nil { return false, currentAttempt.outcome, 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. + // 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(lastAttempt.err) { return true, lastAttempt.outcome, lastAttempt.err } return true, currentAttempt.outcome, currentAttempt.err } +// Records a process probe that could not answer whether Unity is running. Why log it at all: the +// probe failure no longer shows up in the returned error, and it is the only clue that the +// diagnosis was decided without a process reading — a sysctl refusal on macOS, or a PowerShell +// launch failure or process-list timeout on Windows. +func logUnityProcessProbeFailure(connection unityipc.Connection, dialErr error, probeErr error) { + _ = vibelog.WriteCLIVibeLog(connection.ProjectRoot, vibelog.CLIVibeLogEntry{ + Level: "WARNING", + Operation: "cli_unity_process_probe_failed", + Message: "Could not determine whether Unity is running while recovering an unreachable request.", + Context: map[string]any{ + "endpoint": connection.Endpoint.Address, + "dial_cause": clicore.ErrorMessage(dialErr), + "cause": clicore.ErrorMessage(probeErr), + }, + CorrelationID: vibelog.NewCLIVibeCorrelationID(), + }) +} + func finishUnityAliveRetryWait( ctx context.Context, retryContext context.Context, diff --git a/cli/project-runner/internal/projectrunner/connection_retry_flow_test.go b/cli/project-runner/internal/projectrunner/connection_retry_flow_test.go index e38f4e0330..ce0d5ec9ee 100644 --- a/cli/project-runner/internal/projectrunner/connection_retry_flow_test.go +++ b/cli/project-runner/internal/projectrunner/connection_retry_flow_test.go @@ -1,9 +1,19 @@ package projectrunner import ( + "context" + "errors" + "os" + "path/filepath" + "strings" "testing" + clierrors "github.com/hatayama/unity-cli-loop/common/errors" + "github.com/hatayama/unity-cli-loop/common/clicore" + "github.com/hatayama/unity-cli-loop/common/unityipc" + "github.com/hatayama/unity-cli-loop/common/unityprocess" + "github.com/hatayama/unity-cli-loop/common/vibelog" ) // Verifies only execute-dynamic-code gets main-thread stall tolerance: other commands' @@ -19,3 +29,147 @@ func TestCommandNeedsSelfInducedStallToleranceOnlyForExecuteDynamicCode(t *testi t.Fatal("expected run-tests to not need self-induced stall tolerance") } } + +func refusedDialAttempt() sendAttempt { + return sendAttempt{ + outcome: unityipc.UnitySendOutcome{}, + err: &unityipc.ConnectionAttemptError{ + ProjectRoot: "/projects/sample", + Endpoint: "/tmp/uloop/sample.sock", + Cause: errors.New("dial unix /tmp/uloop/sample.sock: connect: connection refused"), + }, + } +} + +func expiredRetryContext() context.Context { + expired, cancel := context.WithCancel(context.Background()) + cancel() + return expired +} + +// Verifies a failed process probe never upgrades the diagnosis to "Unity is running": the probe +// observed nothing, so the dial error must be reported exactly as it is on the no-process path. +func TestFinishUndispatchedRetryProbeDoesNotClaimUnityIsRunningWhenTheProbeFailed(t *testing.T) { + currentAttempt := refusedDialAttempt() + + finished, _, err := finishUndispatchedRetryProbe( + expiredRetryContext(), + unityipc.Connection{ProjectRoot: t.TempDir()}, + currentAttempt, + errors.New("sysctl kern.proc.all: operation not permitted"), + nil, + sendAttempt{}, + ) + + if !finished { + t.Fatal("expected the retry loop to finish after a failed probe with an expired window") + } + var notResponding clierrors.UnityServerNotRespondingError + if errors.As(err, ¬Responding) { + t.Fatalf("a failed probe must not report Unity as running: %v", err) + } + if err != currentAttempt.err { + t.Fatalf("expected the dial error verbatim, got: %v", err) + } +} + +// Verifies the same fallback applies while the retry window is still alive: the probe failure +// alone would hide the dial error, which is the fact the caller acts on. +func TestFinishUndispatchedRetryProbeReportsTheDialErrorWhileTheWindowIsAlive(t *testing.T) { + currentAttempt := refusedDialAttempt() + probeErr := errors.New("listing Unity processes timed out") + + finished, _, err := finishUndispatchedRetryProbe( + context.Background(), + unityipc.Connection{ProjectRoot: t.TempDir()}, + currentAttempt, + probeErr, + nil, + sendAttempt{}, + ) + + if !finished { + t.Fatal("expected the retry loop to finish after a failed probe") + } + if err != currentAttempt.err { + t.Fatalf("expected the dial error verbatim, got: %v", err) + } +} + +// Verifies a busy response seen earlier in the window still wins over the final dial error when +// the probe failed, because a server that answered moments ago is the truer diagnosis. +func TestFinishUndispatchedRetryProbeKeepsABusyResponseWhenTheProbeFailed(t *testing.T) { + busyAttempt := sendAttempt{ + err: &unityipc.RPCError{ + Code: -32603, + Message: "Unity is busy running 'compile'.", + Data: []byte(`{"type":"server_busy"}`), + }, + } + + finished, _, err := finishUndispatchedRetryProbe( + expiredRetryContext(), + unityipc.Connection{ProjectRoot: t.TempDir()}, + refusedDialAttempt(), + errors.New("sysctl kern.proc.all: operation not permitted"), + nil, + busyAttempt, + ) + + if !finished { + t.Fatal("expected the retry loop to finish after a failed probe with an expired window") + } + if err != busyAttempt.err { + t.Fatalf("expected the busy response to be preserved, got: %v", err) + } +} + +// Verifies a probe that found a running process still lets the retry loop continue. +func TestFinishUndispatchedRetryProbeContinuesWhenUnityIsRunning(t *testing.T) { + finished, _, err := finishUndispatchedRetryProbe( + context.Background(), + unityipc.Connection{ProjectRoot: t.TempDir()}, + refusedDialAttempt(), + nil, + &unityprocess.UnityProcess{Pid: 4321}, + sendAttempt{}, + ) + + if finished { + t.Fatalf("expected the retry loop to continue while Unity is running, got: %v", err) + } + if err != nil { + t.Fatalf("expected no error while continuing, got: %v", err) + } +} + +// Verifies the swallowed probe failure is still recorded: dropping the diagnosis from the error +// must not drop it from the diagnostics too. +func TestFinishUndispatchedRetryProbeRecordsTheProbeFailureInTheVibeLog(t *testing.T) { + projectRoot := t.TempDir() + t.Setenv(vibelog.CLIVibeLogEnvName, "1") + + _, _, _ = finishUndispatchedRetryProbe( + expiredRetryContext(), + unityipc.Connection{ProjectRoot: projectRoot}, + refusedDialAttempt(), + errors.New("sysctl kern.proc.all: operation not permitted"), + nil, + sendAttempt{}, + ) + + entries, globErr := filepath.Glob(filepath.Join(projectRoot, vibelog.CLIVibeLogDirectory, "*.json")) + if globErr != nil { + t.Fatalf("reading the vibe log directory failed: %v", globErr) + } + if len(entries) == 0 { + t.Fatal("expected the failed process probe to be written to the CLI vibe log") + } + contents, readErr := os.ReadFile(entries[0]) + if readErr != nil { + t.Fatalf("reading the vibe log failed: %v", readErr) + } + if !strings.Contains(string(contents), "operation not permitted") { + t.Fatalf("the probe failure was not recorded: %s", contents) + } +} diff --git a/cli/project-runner/internal/projectrunner/connection_retry_test.go b/cli/project-runner/internal/projectrunner/connection_retry_test.go index 779d6423dd..604f5b9832 100644 --- a/cli/project-runner/internal/projectrunner/connection_retry_test.go +++ b/cli/project-runner/internal/projectrunner/connection_retry_test.go @@ -10,6 +10,7 @@ import ( "path/filepath" "runtime" "strings" + "syscall" "testing" "time" @@ -202,8 +203,10 @@ func TestSendWithTransientConnectionRetryWritesFocusFailureVibeLog(t *testing.T) } } -// Verifies process probe timeouts keep the structured server-not-responding error. -func TestSendWithTransientConnectionRetryClassifiesProcessProbeTimeout(t *testing.T) { +// Verifies a process probe that timed out reports the dial error instead of the +// server-not-responding error: a probe that never read the process table cannot be the evidence +// for claiming Unity is running. +func TestSendWithTransientConnectionRetryReportsTheDialErrorWhenTheProcessProbeTimesOut(t *testing.T) { deps := defaultConnectionRetryDeps() deps.findRunningUnityProcess = func(ctx context.Context, projectRoot string) (*clicore.UnityProcess, error) { <-ctx.Done() @@ -215,7 +218,7 @@ func TestSendWithTransientConnectionRetryClassifiesProcessProbeTimeout(t *testin connection := unityipc.Connection{ Endpoint: unityipc.Endpoint{ Network: "unix", - Address: t.TempDir() + "/missing.sock", + Address: endpointDirectoryWithRequiredMode(t) + "/missing.sock", }, ProjectRoot: t.TempDir(), } @@ -230,8 +233,75 @@ func TestSendWithTransientConnectionRetryClassifiesProcessProbeTimeout(t *testin deps) var notRespondingErr clierrors.UnityServerNotRespondingError - if !errors.As(err, ¬RespondingErr) { - t.Fatalf("expected unityServerNotRespondingError, got %v", err) + if errors.As(err, ¬RespondingErr) { + t.Fatalf("a failed process probe must not report Unity as running: %v", err) + } + var connectionErr *unityipc.ConnectionAttemptError + if !errors.As(err, &connectionErr) { + t.Fatalf("expected the connection attempt error, got %v", err) + } +} + +// Endpoint validation rejects any directory that is not 0700, which a plain t.TempDir() is not. +// Tests that need the dial itself to fail must get past that check first. +func endpointDirectoryWithRequiredMode(t *testing.T) string { + t.Helper() + directory := t.TempDir() + if err := os.Chmod(directory, 0o700); err != nil { + t.Fatalf("failed to set the endpoint directory mode: %v", err) + } + return directory +} + +// Verifies a cancelled command reports the cancellation rather than an unreachable Unity: the +// process probe inherits the cancellation and fails, and that failure must not be turned into +// "Unity may be closed, run uloop launch" guidance for a user who pressed Ctrl-C. +func TestSendWithTransientConnectionRetryPreservesParentCancellation(t *testing.T) { + projectRoot := t.TempDir() + t.Setenv(vibelog.CLIVibeLogEnvName, "1") + + deps := defaultConnectionRetryDeps() + deps.findRunningUnityProcess = func(ctx context.Context, projectRoot string) (*clicore.UnityProcess, error) { + return nil, ctx.Err() + } + deps.retryPoll = time.Nanosecond + + cancelledContext, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := sendWithTransientConnectionRetryWithDeps( + cancelledContext, + unityipc.Connection{ + Endpoint: unityipc.Endpoint{ + Network: "unix", + Address: endpointDirectoryWithRequiredMode(t) + "/missing.sock", + }, + ProjectRoot: projectRoot, + }, + "get-logs", + map[string]any{}, + nil, + 0, + deps) + + // Identity, not errors.Is: a dial cut short by the cancellation wraps context.Canceled too, so + // errors.Is holds with or without the guard. The contract is that the cancellation itself comes + // back, because anything wrapping it is classified as an unreachable Unity. + if err != context.Canceled { + t.Fatalf("expected the cancellation to be preserved, got %v", err) + } + logFiles, globErr := filepath.Glob(filepath.Join(projectRoot, vibelog.CLIVibeLogDirectory, "*.json")) + if globErr != nil { + t.Fatalf("reading the vibe log directory failed: %v", globErr) + } + for _, logFile := range logFiles { + contents, readErr := os.ReadFile(logFile) + if readErr != nil { + t.Fatalf("reading the vibe log failed: %v", readErr) + } + if strings.Contains(string(contents), "cli_unity_process_probe_failed") { + t.Fatalf("a cancelled command must not record a process probe failure: %s", contents) + } } } @@ -390,6 +460,114 @@ func TestConnectionRetryFocusControllerRetriesAfterProcessDiscoveryMiss(t *testi } } +// Verifies a connect() the operating system refused permanently is not retried. Retrying it +// burned the whole 60-second window and then reported the window's own deadline error, hiding +// the real syscall error the first attempt already had. +func TestShouldNotRetryPermanentlyRefusedConnection(t *testing.T) { + err := &unityipc.ConnectionAttemptError{ + Endpoint: "/tmp/uloop-501/UnityCliLoop-sample.sock", + Cause: &net.OpError{ + Op: "dial", + Net: "unix", + Addr: &net.UnixAddr{Name: "/tmp/uloop-501/UnityCliLoop-sample.sock", Net: "unix"}, + Err: os.NewSyscallError("connect", syscall.EPERM), + }, + } + + if shouldRetryUndispatchedConnection(err, unityipc.UnitySendOutcome{}) { + t.Fatal("a permanently refused connect must not enter the retry loop") + } +} + +// Verifies the whole send path fails at the first attempt when the operating system refuses the +// connect, and reports that syscall error itself. Retrying it consumed the extended +// unity-alive window and then reported the window's own deadline as `i/o timeout`. +func TestSendWithTransientConnectionRetryAbortsOnRefusedConnect(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix socket file permissions do not apply to named pipes") + } + if os.Geteuid() == 0 { + t.Skip("root bypasses socket file permissions") + } + + // Why a real listening socket with mode 000: the refusal has to come from the kernel's + // connect, which is the only thing that produces the errno this path classifies. The + // directory comes from MkdirTemp rather than t.TempDir because a socket path built from this + // test's name exceeds the sockaddr_un limit. + endpointDirectory, err := os.MkdirTemp("", "uloop-refused") + if err != nil { + t.Fatalf("failed to create the endpoint directory: %v", err) + } + t.Cleanup(func() { + _ = os.RemoveAll(endpointDirectory) + }) + socketPath := filepath.Join(endpointDirectory, "refused.sock") + listener, listenErr := net.Listen("unix", socketPath) + if listenErr != nil { + t.Fatalf("failed to listen on the endpoint: %v", listenErr) + } + defer func() { + _ = listener.Close() + }() + if err := os.Chmod(socketPath, 0o000); err != nil { + t.Fatalf("failed to remove socket permissions: %v", err) + } + + deps := defaultConnectionRetryDeps() + deps.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { + return &clicore.UnityProcess{Pid: 123}, nil + } + connection := unityipc.Connection{ + Endpoint: unityipc.Endpoint{Network: "unix", Address: socketPath}, + ProjectRoot: t.TempDir(), + } + + startedAt := time.Now() + _, sendErr := sendWithTransientConnectionRetryWithDeps( + context.Background(), + connection, + "get-logs", + map[string]any{}, + nil, + 0, + deps) + elapsed := time.Since(startedAt) + + var connectionErr *unityipc.ConnectionAttemptError + if !errors.As(sendErr, &connectionErr) { + t.Fatalf("expected the connection attempt error, got %v", sendErr) + } + var notRespondingErr clierrors.UnityServerNotRespondingError + if errors.As(sendErr, ¬RespondingErr) { + t.Fatalf("a refused connect must not be reported as a non-responding server: %v", sendErr) + } + if !clierrors.IsPermanentConnectError(sendErr) { + t.Fatalf("the syscall error was replaced on the way out: %v", sendErr) + } + if elapsed >= deps.retryPoll { + t.Fatalf("expected the send to abort before the first retry wait, took %s", elapsed) + } +} + +// Verifies the dial failures the retry window exists for — the socket not created yet, nobody +// listening yet — keep being retried. +func TestShouldRetryTransientlyFailedConnection(t *testing.T) { + transientCauses := []error{ + os.NewSyscallError("connect", syscall.ENOENT), + os.NewSyscallError("connect", syscall.ECONNREFUSED), + } + + for _, cause := range transientCauses { + err := &unityipc.ConnectionAttemptError{ + Endpoint: "/tmp/uloop-501/UnityCliLoop-sample.sock", + Cause: cause, + } + if !shouldRetryUndispatchedConnection(err, unityipc.UnitySendOutcome{}) { + t.Fatalf("a transient dial failure must stay retryable: %v", cause) + } + } +} + // Verifies accepted RPCs can outlive the pre-dispatch connection retry timeout. func TestSendWithTransientConnectionRetryDoesNotCancelAcceptedRequestAtRetryTimeout(t *testing.T) { if runtime.GOOS == "windows" { diff --git a/cli/project-runner/internal/projectrunner/list_output.go b/cli/project-runner/internal/projectrunner/list_output.go index 12525a2fbb..2984989dbd 100644 --- a/cli/project-runner/internal/projectrunner/list_output.go +++ b/cli/project-runner/internal/projectrunner/list_output.go @@ -4,6 +4,7 @@ import ( "encoding/json" "sort" + "github.com/hatayama/unity-cli-loop/common/skilldocs" "github.com/hatayama/unity-cli-loop/common/tooldocs" "github.com/hatayama/unity-cli-loop/common/clicontract" @@ -31,12 +32,21 @@ type listOption struct { Values []string `json:"Values,omitempty"` } -func formatToolListResult(result json.RawMessage) json.RawMessage { +func formatToolListResult(result json.RawMessage, projectRoot string) json.RawMessage { var cache clicore.ToolsCache if err := json.Unmarshal(result, &cache); err != nil { return result } + // list formats get-tool-details' raw response and never goes through the project-cache loader, + // so the placeholder fallback has to be applied here too. Without it `--help` would show real + // descriptions while `list` kept reporting "Parameter: ". + cache = clicore.ApplyEmbeddedDescriptionFallback(cache) + + // The installed package's SKILL.md tables win over both the cache and the embedded catalog, so + // list reports the same text `uloop --help` does. + cache = skilldocs.ApplyToCatalog(cache, projectRoot) + content, err := json.Marshal(newListCatalog(cache)) if err != nil { panic(err) @@ -45,9 +55,9 @@ func formatToolListResult(result json.RawMessage) json.RawMessage { } func newListCatalog(cache clicore.ToolsCache) listCatalog { - tools := make([]listTool, 0, len(cache.Tools)) + listTools := make([]listTool, 0, len(cache.Tools)) for _, tool := range cache.Tools { - tools = append(tools, newListTool(tool)) + listTools = append(listTools, newListTool(tool)) } // Sourced from the embedded CLI contract because the tool catalog no longer @@ -56,7 +66,7 @@ func newListCatalog(cache clicore.ToolsCache) listCatalog { Version: clicontract.ProjectRunnerVersion(), ServerVersion: cache.ServerVersion, UpdatedAt: cache.UpdatedAt, - Tools: tools, + Tools: listTools, } } @@ -101,36 +111,16 @@ func listOptionDefault(property clicore.ToolProperty) any { return false } defaultValue := property.EffectiveDefault() - if enumValue, ok := enumValueForNumericDefault(defaultValue, property.Enum); ok { + if enumValue, ok := tooldocs.EnumValueForNumericDefault(defaultValue, property.Enum); ok { return enumValue } - return defaultValue -} - -func enumValueForNumericDefault(defaultValue any, values []string) (string, bool) { - if len(values) == 0 || defaultValue == nil { - return "", false + // Unity reports an empty string as the default of every unset string parameter. Reporting it + // would claim the option has a default of "", and omitempty cannot drop it on its own because the + // field is an interface. Nil keeps list symmetric with --help, which omits the same value. + if defaultValue == "" { + return nil } - - switch value := defaultValue.(type) { - case int: - return enumValueAtIndex(value, values) - case float64: - index := int(value) - if value != float64(index) { - return "", false - } - return enumValueAtIndex(index, values) - default: - return "", false - } -} - -func enumValueAtIndex(index int, values []string) (string, bool) { - if index < 0 || index >= len(values) { - return "", false - } - return values[index], true + return defaultValue } func appendDynamicCodeFileListOption(tool clicore.ToolDefinition, options []listOption) []listOption { @@ -149,51 +139,29 @@ func appendDynamicCodeFileListOption(tool clicore.ToolDefinition, options []list }) } -// appendPausePointEnableAwaitListOptions documents --await/--captured-variables/ -// --captured-variable-names on enable-pause-point's catalog entry, mirroring -// appendDynamicCodeFileListOption: these are CLI-only orchestration flags (pause_point_enable.go) -// that are not part of the Unity-side EnablePausePointSchema, so they never appear in -// listOptionsForTool's schema-driven loop above. +// appendPausePointEnableAwaitListOptions documents enable-pause-point's CLI-only orchestration +// flags on its catalog entry, mirroring appendDynamicCodeFileListOption: they are not part of the +// Unity-side EnablePausePointSchema, so they never appear in listOptionsForTool's schema-driven +// loop above. The flag table itself is shared with the dispatcher's `--help` renderer +// (tooldocs.PausePointEnableCLIOnlyOptions) so the two listings cannot drift apart. func appendPausePointEnableAwaitListOptions(tool clicore.ToolDefinition, options []listOption) []listOption { if tool.Name != pausePointEnableCommandName { return options } - if hasListOption(options, "--"+pausePointEnableAwaitFlagName) { - return options + + for _, option := range tooldocs.PausePointEnableCLIOnlyOptions() { + optionName := "--" + option.FlagName + if hasListOption(options, optionName) { + continue + } + options = append(options, listOption{ + Name: optionName, + Type: option.Type, + Description: option.Description, + Values: option.Values, + }) } - return append(options, - listOption{ - Name: "--" + pausePointEnableAwaitFlagName, - Type: "boolean", - Description: "Wait for the marker to be hit (or time out) after enabling, in a single call, instead of a separate await-pause-point call", - }, - listOption{ - Name: "--" + PausePointCapturedVariablesFlagName, - Type: "string", - Description: "Requires --await. Same as await-pause-point's --captured-variables", - Values: []string{string(pausePointCapturedVariablesModeFull), string(pausePointCapturedVariablesModeNames)}, - }, - listOption{ - Name: "--" + PausePointCapturedVariableNamesFlagName, - Type: "string", - Description: "Requires --await. Same as await-pause-point's --captured-variable-names", - }, - listOption{ - Name: "--" + PausePointExpectFlagName, - Type: "string", - Description: "Requires --await. Same as await-pause-point's --expect (repeatable)", - }, - listOption{ - Name: "--" + PausePointTriggerFlagName, - Type: "string", - Description: "Requires --await. Same as await-pause-point's --trigger", - }, - listOption{ - Name: "--" + PausePointResumePlayFlagName, - Type: "boolean", - Description: "Requires --await. After confirming the marker is armed, resume PlayMode if paused (before --trigger), so a paused-arm workflow can fire input in one call", - }, - ) + return options } func hasListOption(options []listOption, name string) bool { diff --git a/cli/project-runner/internal/projectrunner/list_output_test.go b/cli/project-runner/internal/projectrunner/list_output_test.go index 372f3fe09c..0116516603 100644 --- a/cli/project-runner/internal/projectrunner/list_output_test.go +++ b/cli/project-runner/internal/projectrunner/list_output_test.go @@ -2,6 +2,8 @@ package projectrunner import ( "encoding/json" + "os" + "path/filepath" "testing" "github.com/hatayama/unity-cli-loop/common/tooldocs" @@ -32,7 +34,7 @@ func TestFormatToolListResultUsesCliOptionNames(t *testing.T) { } } ] -}`)) +}`), "") catalog := decodeListCatalog(t, result) tool := findListTool(t, catalog, "screenshot") @@ -143,12 +145,137 @@ func TestNewListCatalogIncludesEnablePausePointAwaitOptions(t *testing.T) { catalog := newListCatalog(clicore.ToolsCache{Tools: []clicore.ToolDefinition{tool}}) enablePausePoint := findListTool(t, catalog, pausePointEnableCommandName) - findListOption(t, enablePausePoint, "--"+pausePointEnableAwaitFlagName) - findListOption(t, enablePausePoint, "--"+PausePointCapturedVariablesFlagName) - findListOption(t, enablePausePoint, "--"+PausePointCapturedVariableNamesFlagName) - findListOption(t, enablePausePoint, "--"+PausePointExpectFlagName) - findListOption(t, enablePausePoint, "--"+PausePointTriggerFlagName) - findListOption(t, enablePausePoint, "--"+PausePointResumePlayFlagName) + findListOption(t, enablePausePoint, "--"+tooldocs.PausePointEnableAwaitFlagName) + findListOption(t, enablePausePoint, "--"+tooldocs.PausePointCapturedVariablesFlagName) + findListOption(t, enablePausePoint, "--"+tooldocs.PausePointCapturedVariableNamesFlagName) + findListOption(t, enablePausePoint, "--"+tooldocs.PausePointExpectFlagName) + findListOption(t, enablePausePoint, "--"+tooldocs.PausePointTriggerFlagName) + findListOption(t, enablePausePoint, "--"+tooldocs.PausePointResumePlayFlagName) +} + +// Tests that list replaces Unity's generated placeholder descriptions with the embedded catalog's +// real text. list formats the raw get-tool-details response, so it does not inherit the fallback the +// project-cache loader applies for `--help`. +func TestFormatToolListResultFillsPlaceholderDescriptions(t *testing.T) { + content := formatToolListResult([]byte(`{ + "tools": [ + { + "name": "simulate-keyboard", + "parameterSchema": { + "Properties": { + "Duration": {"Type": "number", "Description": "Parameter: Duration"} + } + } + } + ] +}`), "") + + catalog := decodeListCatalog(t, content) + simulateKeyboard := findListTool(t, catalog, "simulate-keyboard") + + if simulateKeyboard.Description == "" { + t.Error("tool description was not filled from the embedded catalog") + } + option := findListOption(t, simulateKeyboard, "--duration") + if option.Description == "Parameter: Duration" || option.Description == "" { + t.Errorf("option placeholder description was not replaced: %q", option.Description) + } +} + +// Tests that an unset string parameter reports no default at all, matching --help. Unity reports an +// empty string for these, which would otherwise claim the option defaults to "". +func TestNewListCatalogOmitsEmptyStringDefaults(t *testing.T) { + catalog := newListCatalog(clicore.ToolsCache{Tools: []clicore.ToolDefinition{{ + Name: "screenshot", + ParameterSchema: clicore.InputSchema{Properties: map[string]clicore.ToolProperty{ + "OutputPath": {Type: "string", Description: "Where to write the file", DefaultValue: ""}, + }}, + }}}) + + option := findListOption(t, findListTool(t, catalog, "screenshot"), "--output-path") + if option.Default != nil { + t.Errorf("empty-string default was reported: %#v", option.Default) + } +} + +// Tests that list reports the description written in the installed package's SKILL.md table, so the +// table an agent reads and the list an agent queries cannot disagree. +func TestFormatToolListResultReadsDescriptionsFromTheInstalledSkill(t *testing.T) { + projectRoot := writeSkillFixtureProject(t, "Parsed straight out of the skill table.") + + content := formatToolListResult([]byte(`{ + "tools": [ + { + "name": "simulate-keyboard", + "parameterSchema": { + "Properties": { + "Duration": {"Type": "number", "Description": "Parameter: Duration"} + } + } + } + ] +}`), projectRoot) + + simulateKeyboard := findListTool(t, decodeListCatalog(t, content), "simulate-keyboard") + if simulateKeyboard.Description != "Simulate keyboard input from the fixture skill." { + t.Errorf("tool description was not read from the skill: %q", simulateKeyboard.Description) + } + option := findListOption(t, simulateKeyboard, "--duration") + if option.Description != "Parsed straight out of the skill table." { + t.Errorf("option description was not read from the skill: %q", option.Description) + } +} + +// Tests that a project with no installed package keeps the previous output, since a missing skill +// must only cost freshness and never the command itself. +func TestFormatToolListResultKeepsEmbeddedTextWithoutASkill(t *testing.T) { + content := formatToolListResult([]byte(`{ + "tools": [ + { + "name": "simulate-keyboard", + "parameterSchema": { + "Properties": { + "Duration": {"Type": "number", "Description": "Parameter: Duration"} + } + } + } + ] +}`), t.TempDir()) + + option := findListOption(t, findListTool(t, decodeListCatalog(t, content), "simulate-keyboard"), "--duration") + if option.Description == "" || option.Description == "Parameter: Duration" { + t.Errorf("the embedded description was lost: %q", option.Description) + } +} + +// writeSkillFixtureProject builds a Unity project holding a uloop package whose simulate-keyboard +// skill documents --duration with the given text. +func writeSkillFixtureProject(t *testing.T, durationDescription string) string { + t.Helper() + + projectRoot := t.TempDir() + packageRoot := filepath.Join(projectRoot, "Packages", "src") + skillDirectory := filepath.Join(packageRoot, "Editor", "FirstPartyTools", "SimulateKeyboard", "Skill") + if err := os.MkdirAll(skillDirectory, 0o755); err != nil { + t.Fatalf("failed to create the skill directory: %v", err) + } + manifest := []byte(`{"name":"io.github.hatayama.uloopmcp"}`) + if err := os.WriteFile(filepath.Join(packageRoot, "package.json"), manifest, 0o644); err != nil { + t.Fatalf("failed to write the package manifest: %v", err) + } + + skill := "---\n" + + "name: uloop-simulate-keyboard\n" + + "toolName: simulate-keyboard\n" + + "description: \"Simulate keyboard input from the fixture skill.\"\n" + + "---\n\n" + + "| Parameter | Type | Default | Description |\n" + + "|-----------|------|---------|-------------|\n" + + "| `--duration` | number | `0` | " + durationDescription + " |\n" + if err := os.WriteFile(filepath.Join(skillDirectory, "SKILL.md"), []byte(skill), 0o644); err != nil { + t.Fatalf("failed to write the fixture skill: %v", err) + } + return projectRoot } func decodeListCatalog(t *testing.T, content []byte) listCatalog { diff --git a/cli/project-runner/internal/projectrunner/native_command_help.go b/cli/project-runner/internal/projectrunner/native_command_help.go index fa058fb94b..b35957333b 100644 --- a/cli/project-runner/internal/projectrunner/native_command_help.go +++ b/cli/project-runner/internal/projectrunner/native_command_help.go @@ -1,25 +1,20 @@ package projectrunner import ( - "fmt" "io" "sort" - clierrors "github.com/hatayama/unity-cli-loop/common/errors" - "github.com/hatayama/unity-cli-loop/common/clicore" "github.com/hatayama/unity-cli-loop/common/tooldocs" ) +// Runner-specific pause-point flags: --id and --timeout-seconds mirror the Unity-side schema, and +// --matching-logs-max-count only exists on the wait commands. The CLI-only flags shared with +// enable-pause-point's --help listing live in tooldocs instead (pause_point_cli_options.go). const ( - PausePointIDFlagName = "id" - PausePointTimeoutFlagName = "timeout-seconds" - PausePointLogsMaxCountFlagName = "matching-logs-max-count" - PausePointCapturedVariablesFlagName = "captured-variables" - PausePointCapturedVariableNamesFlagName = "captured-variable-names" - PausePointExpectFlagName = "expect" - PausePointTriggerFlagName = "trigger" - PausePointResumePlayFlagName = "resume-play" + PausePointIDFlagName = "id" + PausePointTimeoutFlagName = "timeout-seconds" + PausePointLogsMaxCountFlagName = "matching-logs-max-count" ) // runnerNativeCommandOptions lists the flags accepted by each runner-owned @@ -31,16 +26,17 @@ var runnerNativeCommandOptions = map[string][]string{ "--" + PausePointIDFlagName, "--" + PausePointTimeoutFlagName, "--" + PausePointLogsMaxCountFlagName, - "--" + PausePointCapturedVariablesFlagName, - "--" + PausePointCapturedVariableNamesFlagName, - "--" + PausePointExpectFlagName, - "--" + PausePointTriggerFlagName, - "--" + PausePointResumePlayFlagName, + "--" + tooldocs.PausePointCapturedVariablesFlagName, + "--" + tooldocs.PausePointCapturedVariableNamesFlagName, + "--" + tooldocs.PausePointExpectFlagName, + "--" + tooldocs.PausePointTriggerFlagName, + "--" + tooldocs.PausePointResumePlayFlagName, }, clicore.PausePointStatusUserCommandName: { "--" + PausePointIDFlagName, - "--" + PausePointCapturedVariablesFlagName, - "--" + PausePointCapturedVariableNamesFlagName, + "--" + tooldocs.PausePointCapturedVariablesFlagName, + "--" + tooldocs.PausePointCapturedVariableNamesFlagName, + "--" + tooldocs.PausePointExpectFlagName, }, } @@ -80,21 +76,12 @@ func printNativeCommandHelp(command string, stdout io.Writer) { clicore.WriteLine(stdout, "") clicore.WriteLine(stdout, "Global options:") clicore.WriteFormat(stdout, " --%s Run against a Unity project outside the current directory\n", tooldocs.ProjectPathFlagName) -} -// pausePointUnknownOptionError reports an unrecognized flag for a runner-owned native -// command. The hint calls out an outdated installed project runner as the likely cause when -// the flag is documented in the skill but this runner build predates it, rather than leaving -// the caller to guess between a typo and a stale binary. -func pausePointUnknownOptionError(command string, name string) *clierrors.ArgumentError { - return &clierrors.ArgumentError{ - Message: fmt.Sprintf( - "Unknown option %q for %s. If the skill documentation mentions this option, the installed "+ - "project runner may be older than the docs — check 'uloop --version' and update the CLI.", - "--"+name, command), - Option: "--" + name, - Command: command, - NextActions: []string{fmt.Sprintf("Run `uloop %s --help` to inspect supported options.", command)}, + // Runner-owned commands print their own help, so the dispatcher's closing skill line never + // reaches them: await-pause-point and pause-point-status need it added here. + if guidance, ok := tooldocs.SkillGuidanceLine(command); ok { + clicore.WriteLine(stdout, "") + clicore.WriteLine(stdout, guidance) } } diff --git a/cli/project-runner/internal/projectrunner/native_command_help_test.go b/cli/project-runner/internal/projectrunner/native_command_help_test.go index dcbe321e03..143f577770 100644 --- a/cli/project-runner/internal/projectrunner/native_command_help_test.go +++ b/cli/project-runner/internal/projectrunner/native_command_help_test.go @@ -51,6 +51,28 @@ func TestRunProjectLocalPausePointStatusHelpListsExpectedFlags(t *testing.T) { } } +// Verifies runner-owned pause-point commands close their help with the instruction to load the +// pause-point skill. The dispatcher never renders these commands' help, so its own closing line +// cannot reach them. +func TestRunProjectLocalPausePointHelpPointsAtTheSkill(t *testing.T) { + for _, command := range []string{"await-pause-point", "pause-point-status"} { + t.Run(command, func(t *testing.T) { + t.Chdir(t.TempDir()) + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := RunProjectLocal(context.Background(), []string{command, "--help"}, &stdout, &stderr) + + if code != 0 { + t.Fatalf("%s --help failed: code=%d stderr=%s", command, code, stderr.String()) + } + if !strings.Contains(stdout.String(), "uloop-pause-point skill") { + t.Fatalf("%s --help must point at the pause-point skill: %s", command, stdout.String()) + } + }) + } +} + // Verifies list/sync/focus-window --help print only the global --project-path // option, since these commands take no command-specific flags. func TestRunProjectLocalNoOptionCommandsHelpListsOnlyGlobalOption(t *testing.T) { diff --git a/cli/project-runner/internal/projectrunner/pause_point_captured_variable_names_filter.go b/cli/project-runner/internal/projectrunner/pause_point_captured_variable_names_filter.go index 5978bb45e5..52eaa599aa 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_captured_variable_names_filter.go +++ b/cli/project-runner/internal/projectrunner/pause_point_captured_variable_names_filter.go @@ -1,6 +1,9 @@ package projectrunner -import "strings" +import ( + "slices" + "strings" +) // parsePausePointCapturedVariableNames splits the comma-separated --captured-variable-names // value into individual names, trimming surrounding whitespace and dropping empty entries. @@ -38,13 +41,17 @@ func filterPausePointCapturedVariablesByName( nameSet[name] = struct{}{} } + matchedNames := map[string]struct{}{} + filteredCurrent, currentMatchCount := filterCapturedVariablesByNameSet(response.CapturedVariables, nameSet) + collectCapturedVariableNames(filteredCurrent, matchedNames) response.CapturedVariables = filteredCurrent totalMatchCount := currentMatchCount history := make([]pausePointCapturedHistoryFrame, len(response.CapturedVariableHistory)) for index, frame := range response.CapturedVariableHistory { filteredFrame, frameMatchCount := filterCapturedVariablesByNameSet(frame.CapturedVariables, nameSet) + collectCapturedVariableNames(filteredFrame, matchedNames) frame.CapturedVariables = filteredFrame totalMatchCount += frameMatchCount history[index] = frame @@ -52,9 +59,38 @@ func filterPausePointCapturedVariablesByName( response.CapturedVariableHistory = history response.CapturedVariableNameFilterNoMatch = totalMatchCount == 0 + response.CapturedVariableNamesNotFound = unmatchedCapturedVariableNames(names, matchedNames) return response } +// unmatchedCapturedVariableNames lists the requested names that matched nothing, keeping the order +// they were requested in so the report reads back against the flag value the caller wrote. A name +// matched anywhere — current variables or any history frame — counts as found. A name requested +// twice is reported once: the list answers "which names have no value", not "how many times each +// was asked for". +func unmatchedCapturedVariableNames(names []string, matchedNames map[string]struct{}) []string { + notFound := make([]string, 0, len(names)) + for _, name := range names { + if _, ok := matchedNames[name]; ok { + continue + } + if slices.Contains(notFound, name) { + continue + } + notFound = append(notFound, name) + } + if len(notFound) == 0 { + return nil + } + return notFound +} + +func collectCapturedVariableNames(variables []pausePointCapturedVariable, names map[string]struct{}) { + for _, variable := range variables { + names[variable.Name] = struct{}{} + } +} + func filterCapturedVariablesByNameSet( variables []pausePointCapturedVariable, nameSet map[string]struct{}, diff --git a/cli/project-runner/internal/projectrunner/pause_point_captured_variable_names_filter_test.go b/cli/project-runner/internal/projectrunner/pause_point_captured_variable_names_filter_test.go index 6d5d5f7956..f1ea9cd846 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_captured_variable_names_filter_test.go +++ b/cli/project-runner/internal/projectrunner/pause_point_captured_variable_names_filter_test.go @@ -2,28 +2,32 @@ package projectrunner import "testing" +// capturedVariableNamesFilterResponse is the fixture both --captured-variable-names test functions +// filter: three current variables, two of which also appear in one history frame. +func capturedVariableNamesFilterResponse() pausePointStatusResponse { + return pausePointStatusResponse{ + CapturedVariables: []pausePointCapturedVariable{ + {Name: "velocity", Scope: "Local", TypeName: "Vector3", Value: pausePointVariableValue("(1,0,0)")}, + {Name: "this", Scope: "This", TypeName: "PlayerController", Value: pausePointVariableValue("PlayerController")}, + {Name: "health", Scope: "Local", TypeName: "Int32", Value: pausePointVariableValue("100")}, + }, + CapturedVariableHistory: []pausePointCapturedHistoryFrame{ + { + HitSequence: 1, + CapturedVariables: []pausePointCapturedVariable{ + {Name: "velocity", Scope: "Local", TypeName: "Vector3", Value: pausePointVariableValue("(0,0,0)")}, + {Name: "health", Scope: "Local", TypeName: "Int32", Value: pausePointVariableValue("100")}, + }, + }, + }, + } +} + // TestFilterPausePointCapturedVariablesByName verifies the --captured-variable-names filter: // single-name selection, multi-name selection, a name with no match, and that it composes with // the --captured-variables mode (filter narrows first, then mode strips values). func TestFilterPausePointCapturedVariablesByName(t *testing.T) { - baseResponse := func() pausePointStatusResponse { - return pausePointStatusResponse{ - CapturedVariables: []pausePointCapturedVariable{ - {Name: "velocity", Scope: "Local", TypeName: "Vector3", Value: pausePointVariableValue("(1,0,0)")}, - {Name: "this", Scope: "This", TypeName: "PlayerController", Value: pausePointVariableValue("PlayerController")}, - {Name: "health", Scope: "Local", TypeName: "Int32", Value: pausePointVariableValue("100")}, - }, - CapturedVariableHistory: []pausePointCapturedHistoryFrame{ - { - HitSequence: 1, - CapturedVariables: []pausePointCapturedVariable{ - {Name: "velocity", Scope: "Local", TypeName: "Vector3", Value: pausePointVariableValue("(0,0,0)")}, - {Name: "health", Scope: "Local", TypeName: "Int32", Value: pausePointVariableValue("100")}, - }, - }, - }, - } - } + baseResponse := capturedVariableNamesFilterResponse t.Run("single name keeps only the matching variable", func(t *testing.T) { result := filterPausePointCapturedVariablesByName(baseResponse(), []string{"velocity"}) @@ -104,3 +108,57 @@ func TestParsePausePointCapturedVariableNames(t *testing.T) { } } } + +// TestFilterPausePointCapturedVariablesByNameReportsNotFound verifies which requested names are +// reported as matching nothing: request order is preserved, a history-only match counts as found, a +// repeat is reported once, and the all-or-nothing flag stays consistent with the list. +func TestFilterPausePointCapturedVariablesByNameReportsNotFound(t *testing.T) { + baseResponse := capturedVariableNamesFilterResponse + + t.Run("reports which requested names matched nothing, in the requested order", func(t *testing.T) { + result := filterPausePointCapturedVariablesByName( + baseResponse(), []string{"shield", "velocity", "armor"}) + if len(result.CapturedVariableNamesNotFound) != 2 || + result.CapturedVariableNamesNotFound[0] != "shield" || + result.CapturedVariableNamesNotFound[1] != "armor" { + t.Fatalf("expected the unmatched names in request order: %#v", result.CapturedVariableNamesNotFound) + } + if result.CapturedVariableNameFilterNoMatch { + t.Fatal("a partial match must not set CapturedVariableNameFilterNoMatch") + } + }) + + t.Run("a name matched only in history is not reported as missing", func(t *testing.T) { + response := baseResponse() + response.CapturedVariables = nil + result := filterPausePointCapturedVariablesByName(response, []string{"health"}) + if len(result.CapturedVariableNamesNotFound) != 0 { + t.Fatalf("a history-only match must count as found: %#v", result.CapturedVariableNamesNotFound) + } + }) + + t.Run("all names missing sets both the list and the no-match flag", func(t *testing.T) { + result := filterPausePointCapturedVariablesByName(baseResponse(), []string{"shield", "armor"}) + if len(result.CapturedVariableNamesNotFound) != 2 { + t.Fatalf("expected both names reported missing: %#v", result.CapturedVariableNamesNotFound) + } + if !result.CapturedVariableNameFilterNoMatch { + t.Fatal("expected CapturedVariableNameFilterNoMatch to stay true when nothing matches") + } + }) + + t.Run("a name requested twice is reported missing once", func(t *testing.T) { + result := filterPausePointCapturedVariablesByName( + baseResponse(), []string{"shield", "shield"}) + if len(result.CapturedVariableNamesNotFound) != 1 { + t.Fatalf("expected the repeated name once: %#v", result.CapturedVariableNamesNotFound) + } + }) + + t.Run("every name matching leaves the missing list empty", func(t *testing.T) { + result := filterPausePointCapturedVariablesByName(baseResponse(), []string{"velocity", "health"}) + if result.CapturedVariableNamesNotFound != nil { + t.Fatalf("expected no missing names: %#v", result.CapturedVariableNamesNotFound) + } + }) +} diff --git a/cli/project-runner/internal/projectrunner/pause_point_captured_variables_mode.go b/cli/project-runner/internal/projectrunner/pause_point_captured_variables_mode.go index 3212cb0639..aac0c0612a 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_captured_variables_mode.go +++ b/cli/project-runner/internal/projectrunner/pause_point_captured_variables_mode.go @@ -2,6 +2,8 @@ package projectrunner import ( clierrors "github.com/hatayama/unity-cli-loop/common/errors" + + "github.com/hatayama/unity-cli-loop/common/tooldocs" ) // pausePointCapturedVariablesMode controls how much of each captured variable's data the CLI @@ -10,9 +12,11 @@ import ( // (via TryGetCapturedValue or pause-point-status) instead of paying for every value up front. type pausePointCapturedVariablesMode string +// Defined from the tooldocs values rather than repeating the literals: the same two strings are +// rendered as --captured-variables' accepted values in both option listings. const ( - pausePointCapturedVariablesModeFull pausePointCapturedVariablesMode = "full" - pausePointCapturedVariablesModeNames pausePointCapturedVariablesMode = "names" + pausePointCapturedVariablesModeFull pausePointCapturedVariablesMode = tooldocs.PausePointCapturedVariablesModeFull + pausePointCapturedVariablesModeNames pausePointCapturedVariablesMode = tooldocs.PausePointCapturedVariablesModeNames ) func parsePausePointCapturedVariablesMode(value string) (pausePointCapturedVariablesMode, error) { @@ -23,7 +27,7 @@ func parsePausePointCapturedVariablesMode(value string) (pausePointCapturedVaria return pausePointCapturedVariablesModeNames, nil default: return "", clierrors.InvalidValueArgumentError( - "--"+PausePointCapturedVariablesFlagName, value, "full or names") + "--"+tooldocs.PausePointCapturedVariablesFlagName, value, "full or names") } } diff --git a/cli/project-runner/internal/projectrunner/pause_point_cli_options_contract_test.go b/cli/project-runner/internal/projectrunner/pause_point_cli_options_contract_test.go new file mode 100644 index 0000000000..2405418b28 --- /dev/null +++ b/cli/project-runner/internal/projectrunner/pause_point_cli_options_contract_test.go @@ -0,0 +1,167 @@ +package projectrunner + +import ( + "testing" + + "github.com/hatayama/unity-cli-loop/common/clicore" + "github.com/hatayama/unity-cli-loop/common/tooldocs" +) + +// pausePointCLIOnlySampleArgs supplies one accepted argv form per CLI-only pause-point flag. A flag +// added to the shared table without an entry here fails the contract tests below rather than +// silently going unchecked. +var pausePointCLIOnlySampleArgs = map[string][]string{ + tooldocs.PausePointEnableAwaitFlagName: {"--await"}, + tooldocs.PausePointCapturedVariablesFlagName: {"--captured-variables", "names"}, + tooldocs.PausePointCapturedVariableNamesFlagName: {"--captured-variable-names", "score"}, + tooldocs.PausePointExpectFlagName: {"--expect", "score=1"}, + tooldocs.PausePointTriggerFlagName: {"--trigger", "simulate-keyboard --action Press --key Space"}, + tooldocs.PausePointResumePlayFlagName: {"--resume-play"}, +} + +// Verifies every CLI-only flag that enable-pause-point's --help advertises is actually consumed by +// the runner's enable-pause-point argument parser. The dispatcher self-updates while the project +// runner stays pinned, so a dispatcher-side table that grows a flag the pinned runner does not +// accept would advertise an option that fails on use; this contract catches that drift. +func TestPausePointEnableHelpOptionsAreAcceptedByTheEnableParser(t *testing.T) { + for _, option := range tooldocs.PausePointEnableCLIOnlyOptions() { + args, ok := pausePointCLIOnlySampleArgs[option.FlagName] + if !ok { + t.Fatalf("no sample argv for --%s: add one to pausePointCLIOnlySampleArgs", option.FlagName) + } + + // Every flag but --await itself requires --await, so it is always passed alongside. + enableArgs := append([]string{"--" + tooldocs.PausePointEnableAwaitFlagName}, args...) + remaining, _, _, _, _, _, _, _, err := extractPausePointEnableAwaitFlags(enableArgs) + if err != nil { + t.Errorf("enable-pause-point parser rejected advertised option --%s: %v", option.FlagName, err) + continue + } + if len(remaining) != 0 { + t.Errorf("enable-pause-point parser did not consume advertised option --%s: remaining %v", + option.FlagName, remaining) + } + } +} + +// Verifies the documented flag set and the parsed flag set are exactly the same. The one-directional +// subset checks below cannot see the original defect from the other side: a flag the parser accepts +// but no listing documents is undiscoverable, which is how all six of these flags came to be missing +// from --help in the first place. +func TestPausePointEnableDocumentedAndParsedFlagsMatch(t *testing.T) { + documented := map[string]bool{} + for _, option := range tooldocs.PausePointEnableCLIOnlyOptions() { + documented[option.FlagName] = true + } + + for flagName := range pausePointEnableFlagHandlers { + if !documented[flagName] { + t.Errorf("the enable-pause-point parser accepts --%s but no option listing documents it", flagName) + } + } + for flagName := range documented { + if _, ok := pausePointEnableFlagHandlers[flagName]; !ok { + t.Errorf("--%s is documented but the enable-pause-point parser does not accept it", flagName) + } + } +} + +// Verifies --await has no =value form: it is passed through to Unity schema parsing, which is the +// behavior the parser had before the handler table replaced its open-coded branches. +func TestPausePointEnableAwaitRejectsValueForm(t *testing.T) { + remaining, await, _, _, _, _, _, _, err := extractPausePointEnableAwaitFlags([]string{"--await=true"}) + if err != nil { + t.Fatalf("--await=true should be passed through, not rejected here: %v", err) + } + if await { + t.Error("--await=true must not enable the wait") + } + if len(remaining) != 1 || remaining[0] != "--await=true" { + t.Errorf("--await=true was not passed through: %v", remaining) + } +} + +// Verifies an unrelated argument is passed through untouched for the schema pipeline. +func TestPausePointEnableLeavesSchemaArgumentsAlone(t *testing.T) { + remaining, _, _, _, _, _, _, _, err := extractPausePointEnableAwaitFlags( + []string{"--id", "marker", "--await", "--line", "42"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expected := []string{"--id", "marker", "--line", "42"} + if len(remaining) != len(expected) { + t.Fatalf("remaining = %v, want %v", remaining, expected) + } + for index, argument := range expected { + if remaining[index] != argument { + t.Fatalf("remaining = %v, want %v", remaining, expected) + } + } +} + +// Verifies the CLI-only flags shared with await-pause-point are accepted by the wait parser as +// well, since enable-pause-point --help documents them as "same as await-pause-point's --x". +func TestPausePointSharedHelpOptionsAreAcceptedByTheWaitParser(t *testing.T) { + for _, option := range tooldocs.PausePointEnableCLIOnlyOptions() { + if option.FlagName == tooldocs.PausePointEnableAwaitFlagName { + // --await only exists on enable-pause-point: await-pause-point is the wait itself. + continue + } + + args, ok := pausePointCLIOnlySampleArgs[option.FlagName] + if !ok { + t.Fatalf("no sample argv for --%s: add one to pausePointCLIOnlySampleArgs", option.FlagName) + } + + waitArgs := append([]string{"--id", "marker"}, args...) + if _, err := parseWaitForPausePointOptions(waitArgs); err != nil { + t.Errorf("await-pause-point parser rejected shared option --%s: %v", option.FlagName, err) + } + } +} + +// runnerNativeCommandSampleArgs supplies one accepted argv form per flag advertised by a +// runner-owned native command's --help. A flag added to runnerNativeCommandOptions without an entry +// here fails the contract test below rather than silently going unchecked. +var runnerNativeCommandSampleArgs = map[string][]string{ + "--" + PausePointIDFlagName: {"--id", "marker"}, + "--" + PausePointTimeoutFlagName: {"--timeout-seconds", "5"}, + "--" + PausePointLogsMaxCountFlagName: {"--matching-logs-max-count", "3"}, + "--" + tooldocs.PausePointCapturedVariablesFlagName: {"--captured-variables", "names"}, + "--" + tooldocs.PausePointCapturedVariableNamesFlagName: {"--captured-variable-names", "score"}, + "--" + tooldocs.PausePointExpectFlagName: {"--expect", "score=1"}, + "--" + tooldocs.PausePointTriggerFlagName: {"--trigger", "focus-window"}, + "--" + tooldocs.PausePointResumePlayFlagName: {"--resume-play"}, +} + +// Verifies every flag pause-point-status advertises in its --help output is actually accepted by its +// own parser. The status parser is a separate switch from the await one, so a flag added to the help +// table alone would be advertised and then rejected as unknown on use. +func TestPausePointStatusHelpOptionsAreAcceptedByTheStatusParser(t *testing.T) { + for _, option := range runnerNativeCommandOptions[clicore.PausePointStatusUserCommandName] { + args, ok := runnerNativeCommandSampleArgs[option] + if !ok { + t.Fatalf("no sample argv for %s: add one to runnerNativeCommandSampleArgs", option) + } + + statusArgs := append([]string{"--" + PausePointIDFlagName, "marker"}, args...) + if _, err := parsePausePointStatusOptions(statusArgs); err != nil { + t.Errorf("pause-point-status parser rejected advertised option %s: %v", option, err) + } + } +} + +// Verifies the same for await-pause-point, whose help table is the larger of the two. +func TestPausePointAwaitHelpOptionsAreAcceptedByTheWaitParser(t *testing.T) { + for _, option := range runnerNativeCommandOptions[clicore.PausePointAwaitCommandName] { + args, ok := runnerNativeCommandSampleArgs[option] + if !ok { + t.Fatalf("no sample argv for %s: add one to runnerNativeCommandSampleArgs", option) + } + + waitArgs := append([]string{"--" + PausePointIDFlagName, "marker"}, args...) + if _, err := parseWaitForPausePointOptions(waitArgs); err != nil { + t.Errorf("await-pause-point parser rejected advertised option %s: %v", option, err) + } + } +} diff --git a/cli/project-runner/internal/projectrunner/pause_point_enable.go b/cli/project-runner/internal/projectrunner/pause_point_enable.go index 31d786d6ff..50c78da18d 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_enable.go +++ b/cli/project-runner/internal/projectrunner/pause_point_enable.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "io" + "slices" "strings" "time" @@ -11,6 +12,7 @@ import ( "github.com/hatayama/unity-cli-loop/common/ui" "github.com/hatayama/unity-cli-loop/common/clicore" + "github.com/hatayama/unity-cli-loop/common/tooldocs" "github.com/hatayama/unity-cli-loop/common/unityipc" ) @@ -22,168 +24,186 @@ import ( // extractDynamicCodeFileFlag intercepts --code-file for execute-dynamic-code. const pausePointEnableCommandName = "enable-pause-point" -const pausePointEnableAwaitFlagName = "await" +// pausePointEnableFlagState accumulates the CLI-only flags pulled out of enable-pause-point's argv. +type pausePointEnableFlagState struct { + await bool + mode pausePointCapturedVariablesMode + modeSet bool + capturedVariableNames []string + namesSet bool + expectations []pausePointExpectation + triggerCommand string + triggerArgs []string + triggerSet bool + resumePlay bool +} + +// pausePointEnableFlagHandler consumes one CLI-only flag. applyBare is set for a flag that may be +// passed with no value, applyValue for a flag that accepts one; --await has no value form at all, so +// `--await=x` is deliberately left for Unity schema parsing exactly as before. +type pausePointEnableFlagHandler struct { + applyBare func(state *pausePointEnableFlagState) + applyValue func(state *pausePointEnableFlagState, value string) error +} + +// pausePointEnableFlagHandlers is keyed by the same flag names the option listings advertise +// (tooldocs.PausePointEnableCLIOnlyOptions), so a flag can no longer be documented without being +// parsed or parsed without being documented — a contract test compares the two key sets. +var pausePointEnableFlagHandlers = map[string]pausePointEnableFlagHandler{ + tooldocs.PausePointEnableAwaitFlagName: { + applyBare: func(state *pausePointEnableFlagState) { state.await = true }, + }, + tooldocs.PausePointResumePlayFlagName: { + applyBare: func(state *pausePointEnableFlagState) { state.resumePlay = true }, + // --resume-play=true|1 must be accepted here too: otherwise the =value form falls through + // to Unity schema parsing and becomes a confusing unrelated error. + applyValue: func(state *pausePointEnableFlagState, value string) error { + if value != "true" && value != "1" { + return clierrors.InvalidValueArgumentError( + "--"+tooldocs.PausePointResumePlayFlagName, value, "boolean flag (pass with no value, or =true)") + } + state.resumePlay = true + return nil + }, + }, + tooldocs.PausePointTriggerFlagName: { + applyValue: func(state *pausePointEnableFlagState, value string) error { + triggerCommand, triggerArgs, err := parsePausePointTriggerCommand(pausePointEnableCommandName, value) + if err != nil { + return err + } + state.triggerCommand = triggerCommand + state.triggerArgs = triggerArgs + state.triggerSet = true + return nil + }, + }, + tooldocs.PausePointCapturedVariablesFlagName: { + applyValue: func(state *pausePointEnableFlagState, value string) error { + mode, err := parsePausePointCapturedVariablesMode(value) + if err != nil { + return err + } + state.mode = mode + state.modeSet = true + return nil + }, + }, + tooldocs.PausePointCapturedVariableNamesFlagName: { + applyValue: func(state *pausePointEnableFlagState, value string) error { + state.capturedVariableNames = parsePausePointCapturedVariableNames(value) + state.namesSet = true + return nil + }, + }, + tooldocs.PausePointExpectFlagName: { + applyValue: func(state *pausePointEnableFlagState, value string) error { + expectation, err := parsePausePointExpectFlagValue(value) + if err != nil { + return err + } + state.expectations = append(state.expectations, expectation) + return nil + }, + }, +} // extractPausePointEnableAwaitFlags pulls the CLI-only --await/--captured-variables/ // --captured-variable-names/--expect/--trigger/--resume-play flags out of enable-pause-point args // before generic schema parsing, because none of them are part of the Unity-side -// EnablePausePointSchema. +// EnablePausePointSchema. Anything this function does not recognize is passed through untouched for +// the generic schema pipeline to handle. func extractPausePointEnableAwaitFlags( args []string, ) ([]string, bool, pausePointCapturedVariablesMode, []string, []pausePointExpectation, string, []string, bool, error) { remaining := make([]string, 0, len(args)) - await := false - mode := pausePointCapturedVariablesModeFull - modeSet := false - var capturedVariableNames []string - namesSet := false - var expectations []pausePointExpectation - var triggerCommand string - var triggerArgs []string - triggerSet := false - resumePlay := false + state := pausePointEnableFlagState{mode: pausePointCapturedVariablesModeFull} for index := 0; index < len(args); index++ { arg := args[index] - if arg == "--"+pausePointEnableAwaitFlagName { - await = true + flagName, handler, ok := findPausePointEnableFlagHandler(arg) + if !ok { + remaining = append(remaining, arg) continue } - if arg == "--"+PausePointResumePlayFlagName { - resumePlay = true + if arg == "--"+flagName && handler.applyBare != nil { + handler.applyBare(&state) continue } - - // --resume-play=true|1 must be accepted here too: otherwise the =value form falls through - // to Unity schema parsing and becomes a confusing unrelated error. - if isPausePointFlag(arg, PausePointResumePlayFlagName) { - name, value, consumedNext, err := clicore.ParseFlagValue(arg, args, index) - if err != nil { - return nil, false, mode, nil, nil, "", nil, false, err - } - if name != PausePointResumePlayFlagName { - remaining = append(remaining, arg) - continue - } - if value != "true" && value != "1" { - return nil, false, mode, nil, nil, "", nil, false, clierrors.InvalidValueArgumentError( - "--"+PausePointResumePlayFlagName, value, "boolean flag (pass with no value, or =true)") - } - resumePlay = true - if consumedNext { - index++ - } + if handler.applyValue == nil { + remaining = append(remaining, arg) continue } - if isPausePointFlag(arg, PausePointTriggerFlagName) { - name, value, consumedNext, err := clicore.ParseFlagValue(arg, args, index) - if err != nil { - return nil, false, mode, nil, nil, "", nil, false, err - } - if name != PausePointTriggerFlagName { - remaining = append(remaining, arg) - continue - } - parsedCommand, parsedArgs, parseErr := parsePausePointTriggerCommand(pausePointEnableCommandName, value) - if parseErr != nil { - return nil, false, mode, nil, nil, "", nil, false, parseErr - } - triggerCommand = parsedCommand - triggerArgs = parsedArgs - triggerSet = true - if consumedNext { - index++ - } - continue + name, value, consumedNext, err := clicore.ParseFlagValue(arg, args, index) + if err != nil { + return nil, false, state.mode, nil, nil, "", nil, false, err } - - if isPausePointFlag(arg, PausePointCapturedVariablesFlagName) { - name, value, consumedNext, err := clicore.ParseFlagValue(arg, args, index) - if err != nil { - return nil, false, mode, nil, nil, "", nil, false, err - } - if name != PausePointCapturedVariablesFlagName { - remaining = append(remaining, arg) - continue - } - parsedMode, err := parsePausePointCapturedVariablesMode(value) - if err != nil { - return nil, false, mode, nil, nil, "", nil, false, err - } - mode = parsedMode - modeSet = true - if consumedNext { - index++ - } + if name != flagName { + remaining = append(remaining, arg) continue } - - if isPausePointFlag(arg, PausePointCapturedVariableNamesFlagName) { - name, value, consumedNext, err := clicore.ParseFlagValue(arg, args, index) - if err != nil { - return nil, false, mode, nil, nil, "", nil, false, err - } - if name != PausePointCapturedVariableNamesFlagName { - remaining = append(remaining, arg) - continue - } - capturedVariableNames = parsePausePointCapturedVariableNames(value) - namesSet = true - if consumedNext { - index++ - } - continue + if err := handler.applyValue(&state, value); err != nil { + return nil, false, state.mode, nil, nil, "", nil, false, err } - - if isPausePointFlag(arg, PausePointExpectFlagName) { - name, value, consumedNext, err := clicore.ParseFlagValue(arg, args, index) - if err != nil { - return nil, false, mode, nil, nil, "", nil, false, err - } - if name != PausePointExpectFlagName { - remaining = append(remaining, arg) - continue - } - expectation, parseErr := parsePausePointExpectFlagValue(value) - if parseErr != nil { - return nil, false, mode, nil, nil, "", nil, false, parseErr - } - expectations = append(expectations, expectation) - if consumedNext { - index++ - } - continue + if consumedNext { + index++ } + } - remaining = append(remaining, arg) + if err := pausePointEnableAwaitRequirementError(state); err != nil { + return nil, false, state.mode, nil, nil, "", nil, false, err } - if !await && (modeSet || namesSet || len(expectations) > 0 || triggerSet || resumePlay) { - option := "--" + PausePointCapturedVariablesFlagName - switch { - case resumePlay: - option = "--" + PausePointResumePlayFlagName - case triggerSet: - option = "--" + PausePointTriggerFlagName - case len(expectations) > 0: - option = "--" + PausePointExpectFlagName - case namesSet: - option = "--" + PausePointCapturedVariableNamesFlagName - } - return nil, false, mode, nil, nil, "", nil, false, &clierrors.ArgumentError{ - Message: "--captured-variables, --captured-variable-names, --expect, --trigger, and --resume-play require --await", - Option: option, - Command: pausePointEnableCommandName, - NextActions: []string{ - "Pass `--await` to wait for the marker after enabling, or drop these options.", - }, + return remaining, state.await, state.mode, state.capturedVariableNames, state.expectations, + state.triggerCommand, state.triggerArgs, state.resumePlay, nil +} + +// findPausePointEnableFlagHandler resolves an argv token to the CLI-only flag it names. The match is +// exact or `--flag=`-prefixed, and the flag names share no such prefix, so at most one handler can +// match regardless of map iteration order. +func findPausePointEnableFlagHandler(arg string) (string, pausePointEnableFlagHandler, bool) { + for flagName, handler := range pausePointEnableFlagHandlers { + if isPausePointFlag(arg, flagName) { + return flagName, handler, true } } + return "", pausePointEnableFlagHandler{}, false +} + +// pausePointEnableAwaitRequirementError rejects the orchestration flags when --await was not passed: +// without the wait there is nothing for them to configure. The reported Option follows a fixed +// priority so the message names one concrete flag instead of whichever the parser saw last. +func pausePointEnableAwaitRequirementError(state pausePointEnableFlagState) error { + if state.await { + return nil + } + if !state.modeSet && !state.namesSet && len(state.expectations) == 0 && !state.triggerSet && !state.resumePlay { + return nil + } - return remaining, await, mode, capturedVariableNames, expectations, triggerCommand, triggerArgs, resumePlay, nil + option := "--" + tooldocs.PausePointCapturedVariablesFlagName + switch { + case state.resumePlay: + option = "--" + tooldocs.PausePointResumePlayFlagName + case state.triggerSet: + option = "--" + tooldocs.PausePointTriggerFlagName + case len(state.expectations) > 0: + option = "--" + tooldocs.PausePointExpectFlagName + case state.namesSet: + option = "--" + tooldocs.PausePointCapturedVariableNamesFlagName + } + + return &clierrors.ArgumentError{ + Message: "--captured-variables, --captured-variable-names, --expect, --trigger, and --resume-play require --await", + Option: option, + Command: pausePointEnableCommandName, + NextActions: []string{ + "Pass `--await` to wait for the marker after enabling, or drop these options.", + }, + } } func isPausePointFlag(arg string, flagName string) bool { @@ -388,35 +408,18 @@ func runPausePointWaitAfterEnable( response = filterPausePointCapturedVariablesByName(response, options.capturedVariableNames) response = applyPausePointCapturedVariablesMode(response, options.capturedVariablesMode) - var payload any = response logs, logsErr := fetchMatchingLogs(ctx, connection, options.id, options.matchingLogsMaxCount) - switch { - case logsErr == nil: - payload = pausePointWaitResult{ - pausePointStatusResponse: response, - MatchingLogs: logs.Logs, - Warning: joinPausePointWarnings(enableFields.Warning, buildPausePointWarning(logs, response.HitCount)), - Expectations: expectations, - AllExpectationsPassed: pausePointAllExpectationsPassedPointer(expectations), - } - case enableFields.Warning != "" || len(expectations) > 0: - // Best-effort like the plain await path: a failed log fetch must not also drop the - // enable-time warning or --expect results, since those are the only evidence left in - // this branch. Uses an anonymous struct (not pausePointWaitResult) so MatchingLogs is - // omitted entirely rather than serialized as an empty array, preserving "empty array - // only means a successful fetch with no matches". - payload = struct { - pausePointStatusResponse - Warning string `json:"Warning,omitempty"` - Expectations []pausePointExpectationResult `json:"Expectations,omitempty"` - AllExpectationsPassed *bool `json:"AllExpectationsPassed,omitempty"` - }{ - pausePointStatusResponse: response, - Warning: enableFields.Warning, - Expectations: expectations, - AllExpectationsPassed: pausePointAllExpectationsPassedPointer(expectations), - } - } + // Unity's warning can come from either the enable response or the status poll that observed + // the hit, so both are passed; the join drops the repeat when they carry the same text. + payload := buildPausePointHitPayload(pausePointHitPayloadInputs{ + response: response, + logs: logs, + logsErr: logsErr, + unityWarning: joinPausePointWarnings(enableFields.Warning, response.Warning), + triggerResult: triggerResult, + awaitedPausePointID: options.id, + expectations: expectations, + }) result, marshalErr := json.Marshal(payload) if marshalErr != nil { clierrors.WriteClassifiedError(stderr, marshalErr, clierrors.ErrorContext{ @@ -458,12 +461,17 @@ func runPausePointWaitAfterEnable( return 1 } +// joinPausePointWarnings concatenates the warnings that apply to one response, dropping empty ones +// and repeats. Repeats are possible because the same text can reach a hit payload from two sources — +// the enable response and the status poll that observed the hit — and printing it twice reads as two +// separate problems. func joinPausePointWarnings(warnings ...string) string { - nonEmpty := make([]string, 0, len(warnings)) + unique := make([]string, 0, len(warnings)) for _, warning := range warnings { - if warning != "" { - nonEmpty = append(nonEmpty, warning) + if warning == "" || slices.Contains(unique, warning) { + continue } + unique = append(unique, warning) } - return strings.Join(nonEmpty, " ") + return strings.Join(unique, " ") } diff --git a/cli/project-runner/internal/projectrunner/pause_point_enable_await_diagnosis_test.go b/cli/project-runner/internal/projectrunner/pause_point_enable_await_diagnosis_test.go new file mode 100644 index 0000000000..ac876ab4aa --- /dev/null +++ b/cli/project-runner/internal/projectrunner/pause_point_enable_await_diagnosis_test.go @@ -0,0 +1,98 @@ +package projectrunner + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/hatayama/unity-cli-loop/common/unityipc" +) + +// runEnableAwaitWithStubbedTrigger drives the enable-pause-point --await hit path, the second hit +// payload builder, with the same stubs the plain await path's tests use. +func runEnableAwaitWithStubbedTrigger(t *testing.T, enableWarning string) (int, string) { + t.Helper() + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runPausePointWaitAfterEnable( + context.Background(), + unityipc.Connection{}, + waitForPausePointOptions{ + id: "jump", + timeoutSeconds: 1, + timeout: time.Second, + matchingLogsMaxCount: 5, + triggerCommand: "simulate-keyboard", + triggerArgs: []string{"--action", "Press", "--key", "W"}, + }, + enablePausePointPropagatedFields{Warning: enableWarning}, + &stdout, + &stderr, + ) + if stderr.Len() > 0 { + t.Logf("stderr: %s", stderr.String()) + } + return code, stdout.String() +} + +// Verifies enable-pause-point --await diagnoses a refused trigger exactly as await-pause-point does: +// the two commands build their hit payloads separately, so a diagnosis wired into only one is +// invisible to callers of the other, which is the form this project's own checklist exercises. +func TestRunPausePointWaitAfterEnableWarnsWhenTheTriggerWasRefusedByThisMarker(t *testing.T) { + stubPausePointHit(t, "") + stubPausePointMatchingLogs(t, nil) + stubPausePointTriggerDispatch(t, pausePointRejectedTriggerResponse("jump")) + + code, output := runEnableAwaitWithStubbedTrigger(t, "") + + if code != 0 { + t.Fatalf("expected the hit to stay a success, got %d: %s", code, output) + } + result := decodePausePointWaitResult(t, output) + if !strings.Contains(result.Warning, "refused") { + t.Errorf("expected a refusal warning: %q", result.Warning) + } + if result.TriggerFailed == nil || !*result.TriggerFailed { + t.Errorf("TriggerFailed must be promoted to the top level: %#v", result.TriggerFailed) + } +} + +// Verifies the enable-time warning survives next to the CLI's refusal warning, and that the +// refusal warning also survives a failed matching-log fetch. +func TestRunPausePointWaitAfterEnableKeepsEnableWarningWithTheRefusalWarning(t *testing.T) { + stubPausePointHit(t, "") + stubPausePointMatchingLogs(t, errors.New("unity busy")) + stubPausePointTriggerDispatch(t, pausePointRejectedTriggerResponse("jump")) + + _, output := runEnableAwaitWithStubbedTrigger(t, "Enable-time warning.") + + if strings.Contains(output, `"MatchingLogs"`) { + t.Errorf("a failed fetch must omit MatchingLogs entirely: %s", output) + } + result := decodePausePointWaitResult(t, output) + if !strings.Contains(result.Warning, "Enable-time warning.") { + t.Errorf("the enable-time warning was dropped: %q", result.Warning) + } + if !strings.Contains(result.Warning, "refused") { + t.Errorf("the refusal warning was dropped: %q", result.Warning) + } +} + +// Verifies a warning reported by both the enable response and the status poll is printed once: +// repeating identical text reads as two separate problems. +func TestRunPausePointWaitAfterEnableReportsARepeatedUnityWarningOnce(t *testing.T) { + stubPausePointHit(t, "Same Unity warning.") + stubPausePointMatchingLogs(t, nil) + stubPausePointTriggerDispatch(t, `{"Success":true}`) + + _, output := runEnableAwaitWithStubbedTrigger(t, "Same Unity warning.") + + result := decodePausePointWaitResult(t, output) + if strings.Count(result.Warning, "Same Unity warning.") != 1 { + t.Errorf("expected the repeated warning exactly once: %q", result.Warning) + } +} diff --git a/cli/project-runner/internal/projectrunner/pause_point_errors.go b/cli/project-runner/internal/projectrunner/pause_point_errors.go index 23003500f0..a1484486ca 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_errors.go +++ b/cli/project-runner/internal/projectrunner/pause_point_errors.go @@ -39,6 +39,24 @@ func pausePointWaitError( expiredError.Details["Hint"] = hint } return expiredError + case pausePointWaitStateTriggerFailed: + triggerFailedError := pausePointStateError( + clierrors.ErrorCodePausePointTriggerFailed, + "The --trigger command was rejected before it ran (argument parsing or an unknown command "+ + "name), so the wait was abandoned instead of waiting out the remaining timeout. This "+ + "command did not clear the marker: see Details.TriggerResult for the rejection and "+ + "Details.RemainingMilliseconds for how long the marker stays armed. A zero "+ + "RemainingMilliseconds with an empty Details.Status means the final status re-read "+ + "failed — run pause-point-status to confirm the marker.", + projectRoot, + options, + response, + // Retrying the identical command reproduces the same rejection: the trigger value has to + // change first. Reporting a permanent failure as retryable is what made the original + // incident waste a full timeout window on it. + false) + triggerFailedError.NextActions = pausePointTriggerFailedNextActions(options.id) + return triggerFailedError case pausePointWaitStateCleared: return pausePointStateError( clierrors.ErrorCodePausePointCleared, @@ -63,6 +81,31 @@ func pausePointWaitError( } } +// pausePointTriggerFailedNextActions replaces the generic enable/id-mismatch guidance, which does +// not apply here: the marker was confirmed armed and only the --trigger value is wrong. +// +// Why re-running the same command comes first: this response answers the command the caller just +// ran, so "fix the --trigger value in that command and run it again" asks them to change one value +// they already typed, with no argument they have to guess. Re-enabling is also the cleaner reset — +// it starts a fresh marker entry (HitCount and IsHit back to zero, the --timeout-seconds countdown +// restarted) while re-patching an id that is already patched is a no-op. +// +// Why the await form carries the real id: it is the one recovery command this function can spell +// out completely, and naming a command without its arguments is exactly the failure this guidance +// exists to prevent. +func pausePointTriggerFailedNextActions(id string) []string { + return []string{ + "Fix the --trigger value in the command you just ran and run that command again. Re-running " + + "`enable-pause-point --await` is safe and is the cleanest reset: it restarts the marker's " + + "HitCount and --timeout-seconds countdown, and re-patching an already patched id is a no-op.", + fmt.Sprintf( + "The marker is still armed, so you can also wait on it directly: "+ + "uloop await-pause-point --id %q --trigger \"\"", id), + "Check the rejected value against the triggered command's own `--help` before retrying, so the " + + "same value is not retried twice.", + } +} + 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." diff --git a/cli/project-runner/internal/projectrunner/pause_point_expect.go b/cli/project-runner/internal/projectrunner/pause_point_expect.go index f1087c4510..652a664fd4 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_expect.go +++ b/cli/project-runner/internal/projectrunner/pause_point_expect.go @@ -4,6 +4,8 @@ import ( "strings" clierrors "github.com/hatayama/unity-cli-loop/common/errors" + + "github.com/hatayama/unity-cli-loop/common/tooldocs" ) // pausePointExpectation is one --expect 'Name=value' assertion parsed from the CLI args. @@ -30,7 +32,7 @@ func parsePausePointExpectFlagValue(value string) (pausePointExpectation, error) if !found || name == "" { return pausePointExpectation{}, &clierrors.ArgumentError{ Message: "Invalid --expect value: " + value, - Option: "--" + PausePointExpectFlagName, + Option: "--" + tooldocs.PausePointExpectFlagName, ExpectedType: "Name=value", NextActions: []string{"Pass `--expect 'Name=value'`, for example `--expect 'Health=100'`."}, } diff --git a/cli/project-runner/internal/projectrunner/pause_point_logs.go b/cli/project-runner/internal/projectrunner/pause_point_logs.go index 9a1206ba75..48f2919ef4 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_logs.go +++ b/cli/project-runner/internal/projectrunner/pause_point_logs.go @@ -52,6 +52,62 @@ type pausePointWaitResult struct { AllExpectationsPassed *bool `json:"AllExpectationsPassed,omitempty"` } +// pausePointHitPayloadInputs gathers everything a hit payload is built from. Both hit paths +// (await-pause-point and enable-pause-point --await) share one builder because they had drifted +// apart before: a field added to one silently stayed missing from the other. +type pausePointHitPayloadInputs struct { + response pausePointStatusResponse + + // logs / logsErr come straight from fetchMatchingLogs. A failed fetch omits MatchingLogs + // entirely rather than emitting an empty array, so "empty array" keeps meaning "the fetch + // succeeded and nothing matched". + logs pausePointMatchingLogsResult + logsErr error + + // unityWarning is Unity's own warning for this hit: the status response's on the plain await + // path, the enable response's on the enable --await path. + unityWarning string + + triggerResult *pausePointTriggerResult + awaitedPausePointID string + expectations []pausePointExpectationResult +} + +// buildPausePointHitPayload assembles the JSON payload for a hit, folding the CLI-side diagnosis +// (trigger outcome, warnings, --expect verdicts) into the Unity response. +func buildPausePointHitPayload(inputs pausePointHitPayloadInputs) any { + response := inputs.response + response.TriggerFailed = pausePointTriggerFailedPointer(inputs.triggerResult) + triggerWarning := pausePointTriggerRefusalWarning(inputs.triggerResult, inputs.awaitedPausePointID) + + if inputs.logsErr != nil { + // Best-effort: a failed log fetch must not also drop the CLI-side evidence — the warnings + // or the --expect results, which are the only evidence left in this branch. + return struct { + pausePointStatusResponse + Warning string `json:"Warning,omitempty"` + Expectations []pausePointExpectationResult `json:"Expectations,omitempty"` + AllExpectationsPassed *bool `json:"AllExpectationsPassed,omitempty"` + }{ + pausePointStatusResponse: response, + Warning: joinPausePointWarnings(inputs.unityWarning, triggerWarning), + Expectations: inputs.expectations, + AllExpectationsPassed: pausePointAllExpectationsPassedPointer(inputs.expectations), + } + } + + return pausePointWaitResult{ + pausePointStatusResponse: response, + MatchingLogs: inputs.logs.Logs, + Warning: joinPausePointWarnings( + inputs.unityWarning, + buildPausePointWarning(inputs.logs, response.HitCount), + triggerWarning), + Expectations: inputs.expectations, + AllExpectationsPassed: pausePointAllExpectationsPassedPointer(inputs.expectations), + } +} + // pausePointAllExpectationsPassedPointer returns nil when no --expect was given, and otherwise // a pointer to whether every expectation passed. func pausePointAllExpectationsPassedPointer(results []pausePointExpectationResult) *bool { diff --git a/cli/project-runner/internal/projectrunner/pause_point_resume_play.go b/cli/project-runner/internal/projectrunner/pause_point_resume_play.go index 08c38e88e6..00f1db771c 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_resume_play.go +++ b/cli/project-runner/internal/projectrunner/pause_point_resume_play.go @@ -21,6 +21,16 @@ type pausePointResumePlayResult struct { WasPaused bool `json:"WasPaused"` Resumed bool `json:"Resumed"` Error string `json:"Error,omitempty"` + + // Repaused reports that the wait put PlayMode back into pause after resuming it, which happens + // when the wait is abandoned because the --trigger command was rejected before it ran. Reported + // rather than kept internal: a caller that asked for a resume must be able to see that the + // Editor is paused again, otherwise the next command's behavior looks unexplained. + Repaused bool `json:"Repaused,omitempty"` + + // RepauseError explains why the re-pause failed. Not hidden: a failed re-pause means gameplay + // keeps running and can still consume the preserved marker's single shot. + RepauseError string `json:"RepauseError,omitempty"` } type controlPlayModeToolResponse struct { @@ -33,9 +43,10 @@ type controlPlayModeToolResponse struct { // trigger ordering without a live Unity connection. var resumePlayModeForPausePoint = resumePlayModeForPausePointFromUnity -// sendControlPlayModeForPausePointResume is overridden in tests so -// resumePlayModeForPausePointFromUnity's Status/Play branches can be exercised without IPC. -var sendControlPlayModeForPausePointResume = sendControlPlayModeForPausePointResumeFromUnity +// sendControlPlayModeForPausePoint is overridden in tests so resumePlayModeForPausePointFromUnity's +// Status/Play branches and the Pause request sent when a wait is abandoned can be exercised +// without IPC. +var sendControlPlayModeForPausePoint = sendControlPlayModeForPausePointFromUnity // resumePlayModeForPausePointFromUnity asks control-play-mode for Status, then sends Play only when // the Editor is currently paused. A Status or Play failure is returned as Error so the wait path @@ -47,7 +58,7 @@ func resumePlayModeForPausePointFromUnity( resumeContext, cancel := context.WithTimeout(ctx, pausePointResumePlayTimeout) defer cancel() - statusResponse, err := sendControlPlayModeForPausePointResume(resumeContext, connection, "Status") + statusResponse, err := sendControlPlayModeForPausePoint(resumeContext, connection, "Status") if err != nil { return pausePointResumePlayResult{ Error: fmt.Sprintf("control-play-mode Status failed: %v", err), @@ -65,7 +76,7 @@ func resumePlayModeForPausePointFromUnity( return pausePointResumePlayResult{WasPaused: false, Resumed: false} } - playResponse, err := sendControlPlayModeForPausePointResume(resumeContext, connection, "Play") + playResponse, err := sendControlPlayModeForPausePoint(resumeContext, connection, "Play") if err != nil { return pausePointResumePlayResult{ WasPaused: true, @@ -88,7 +99,39 @@ func resumePlayModeForPausePointFromUnity( return pausePointResumePlayResult{WasPaused: true, Resumed: true} } -func sendControlPlayModeForPausePointResumeFromUnity( +// repausePlayModeAfterAbandonedWait puts PlayMode back into pause after this command's own +// --resume-play resumed it, once the wait is abandoned because the --trigger command was rejected +// before it ran. Why re-pause: the marker is deliberately left armed so the trigger can be fixed +// and retried, but a marker left armed while gameplay keeps running can have its single shot +// consumed by unrelated game activity reaching the same line — the retry would then return +// someone else's hit as if it were the fixed trigger's result. +func repausePlayModeAfterAbandonedWait( + ctx context.Context, + connection unityipc.Connection, + resumeResult pausePointResumePlayResult, +) pausePointResumePlayResult { + pauseContext, cancel := context.WithTimeout(ctx, pausePointResumePlayTimeout) + defer cancel() + + response, err := sendControlPlayModeForPausePoint(pauseContext, connection, "Pause") + if err != nil { + resumeResult.RepauseError = fmt.Sprintf("control-play-mode Pause failed: %v", err) + return resumeResult + } + if !response.Success { + message := response.Message + if message == "" { + message = "control-play-mode Pause returned Success=false" + } + resumeResult.RepauseError = message + return resumeResult + } + + resumeResult.Repaused = true + return resumeResult +} + +func sendControlPlayModeForPausePointFromUnity( ctx context.Context, connection unityipc.Connection, action string, diff --git a/cli/project-runner/internal/projectrunner/pause_point_resume_play_test.go b/cli/project-runner/internal/projectrunner/pause_point_resume_play_test.go index 5eb4124ab0..acc5bd08fd 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_resume_play_test.go +++ b/cli/project-runner/internal/projectrunner/pause_point_resume_play_test.go @@ -491,12 +491,12 @@ func TestWaitForPausePointResumesWithoutTriggerWhenArmed(t *testing.T) { // Verifies resumePlayModeForPausePointFromUnity's Status/Play branches without a live Unity IPC. func TestResumePlayModeForPausePointFromUnityBranches(t *testing.T) { - originalSend := sendControlPlayModeForPausePointResume - defer func() { sendControlPlayModeForPausePointResume = originalSend }() + originalSend := sendControlPlayModeForPausePoint + defer func() { sendControlPlayModeForPausePoint = originalSend }() t.Run("Status transport failure", func(t *testing.T) { actions := make([]string, 0, 1) - sendControlPlayModeForPausePointResume = func( + sendControlPlayModeForPausePoint = func( ctx context.Context, connection unityipc.Connection, action string, @@ -516,7 +516,7 @@ func TestResumePlayModeForPausePointFromUnityBranches(t *testing.T) { t.Run("Status Success=false", func(t *testing.T) { actions := make([]string, 0, 1) - sendControlPlayModeForPausePointResume = func( + sendControlPlayModeForPausePoint = func( ctx context.Context, connection unityipc.Connection, action string, @@ -536,7 +536,7 @@ func TestResumePlayModeForPausePointFromUnityBranches(t *testing.T) { t.Run("IsPaused=false skips Play", func(t *testing.T) { actions := make([]string, 0, 1) - sendControlPlayModeForPausePointResume = func( + sendControlPlayModeForPausePoint = func( ctx context.Context, connection unityipc.Connection, action string, @@ -556,7 +556,7 @@ func TestResumePlayModeForPausePointFromUnityBranches(t *testing.T) { t.Run("Play transport failure", func(t *testing.T) { actions := make([]string, 0, 2) - sendControlPlayModeForPausePointResume = func( + sendControlPlayModeForPausePoint = func( ctx context.Context, connection unityipc.Connection, action string, diff --git a/cli/project-runner/internal/projectrunner/pause_point_status_expect_test.go b/cli/project-runner/internal/projectrunner/pause_point_status_expect_test.go new file mode 100644 index 0000000000..84d3646d3a --- /dev/null +++ b/cli/project-runner/internal/projectrunner/pause_point_status_expect_test.go @@ -0,0 +1,224 @@ +package projectrunner + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" + + "github.com/hatayama/unity-cli-loop/common/clicore" + "github.com/hatayama/unity-cli-loop/common/unityipc" +) + +// pausePointStatusExpectPayload decodes only the expectation fields the CLI adds on top of the +// Unity status response, so these tests assert the wire names --expect callers actually read. +type pausePointStatusExpectPayload struct { + Status string `json:"Status"` + CapturedVariables []pausePointCapturedVariable `json:"CapturedVariables"` + Expectations []pausePointExpectationResult `json:"Expectations"` + AllExpectationsPassed *bool `json:"AllExpectationsPassed"` +} + +func stubPausePointStatusHitWithSpeed(t *testing.T, speed string) { + t.Helper() + + originalQuery := queryPausePointStatus + t.Cleanup(func() { + queryPausePointStatus = originalQuery + }) + + queryPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + return pausePointStatusResponse{ + Success: true, + Id: id, + Status: pausePointStatusHit, + IsEnabled: true, + IsHit: true, + HitCount: 1, + CapturedVariables: []pausePointCapturedVariable{ + {Name: "speed", Scope: "Local", TypeName: "System.Int32", Value: pausePointVariableValue(speed)}, + }, + }, nil + } +} + +func runPausePointStatusForExpect(t *testing.T, args []string) (int, string) { + t.Helper() + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runPausePointStatusCommand( + context.Background(), + unityipc.Connection{ProjectRoot: "/tmp/MyProject"}, + args, + &stdout, + &stderr) + if stderr.Len() > 0 { + t.Logf("stderr: %s", stderr.String()) + } + return code, stdout.String() +} + +func decodePausePointStatusExpectPayload(t *testing.T, output string) pausePointStatusExpectPayload { + t.Helper() + + var payload pausePointStatusExpectPayload + if err := json.Unmarshal([]byte(output), &payload); err != nil { + t.Fatalf("stdout is not valid JSON: %v\n%s", err, output) + } + return payload +} + +// Verifies pause-point-status --expect reports each expectation and the aggregate verdict using the +// same field names await-pause-point already emits, so one query shape works for both commands. +func TestRunPausePointStatusEvaluatesExpectations(t *testing.T) { + stubPausePointStatusHitWithSpeed(t, "5") + + code, output := runPausePointStatusForExpect(t, []string{"--id", "jump", "--expect", "speed=5"}) + + if code != 0 { + t.Fatalf("expected success, got %d: %s", code, output) + } + payload := decodePausePointStatusExpectPayload(t, output) + if len(payload.Expectations) != 1 { + t.Fatalf("expected 1 expectation, got %#v", payload.Expectations) + } + expectation := payload.Expectations[0] + if expectation.Name != "speed" || expectation.Expected != "5" || expectation.Actual != "5" || + !expectation.Found || !expectation.Passed { + t.Fatalf("expectation mismatch: %#v", expectation) + } + if payload.AllExpectationsPassed == nil || !*payload.AllExpectationsPassed { + t.Fatalf("AllExpectationsPassed mismatch: %#v", payload.AllExpectationsPassed) + } +} + +// Verifies a failed expectation still exits 0: querying the hit succeeded, and the expectation +// verdict is reported in the payload rather than through the process exit code. +func TestRunPausePointStatusFailedExpectationKeepsExitCodeZero(t *testing.T) { + stubPausePointStatusHitWithSpeed(t, "5") + + code, output := runPausePointStatusForExpect(t, []string{"--id", "jump", "--expect", "speed=9"}) + + if code != 0 { + t.Fatalf("expected exit code 0 for a failed expectation, got %d: %s", code, output) + } + payload := decodePausePointStatusExpectPayload(t, output) + if len(payload.Expectations) != 1 || payload.Expectations[0].Passed { + t.Fatalf("expected a failing expectation, got %#v", payload.Expectations) + } + if payload.AllExpectationsPassed == nil || *payload.AllExpectationsPassed { + t.Fatalf("AllExpectationsPassed must be present and false: %#v", payload.AllExpectationsPassed) + } +} + +// Verifies expectations are evaluated before --captured-variables names strips values, so the +// requested value is still compared even though the response itself reports names only. +func TestRunPausePointStatusEvaluatesExpectationsBeforeNamesModeStripsValues(t *testing.T) { + stubPausePointStatusHitWithSpeed(t, "5") + + code, output := runPausePointStatusForExpect( + t, []string{"--id", "jump", "--captured-variables", "names", "--expect", "speed=5"}) + + if code != 0 { + t.Fatalf("expected success, got %d: %s", code, output) + } + payload := decodePausePointStatusExpectPayload(t, output) + if len(payload.Expectations) != 1 || !payload.Expectations[0].Passed || + payload.Expectations[0].Actual != "5" { + t.Fatalf("expectation must be evaluated against the unfiltered value: %#v", payload.Expectations) + } + if len(payload.CapturedVariables) != 1 || payload.CapturedVariables[0].Value != nil { + t.Fatalf("names mode must still strip Value: %#v", payload.CapturedVariables) + } +} + +// Verifies expectations are evaluated before the --captured-variable-names filter narrows the +// response, so an --expect target that was not also requested by name is not reported as missing. +func TestRunPausePointStatusEvaluatesExpectationsBeforeNameFilter(t *testing.T) { + stubPausePointStatusHitWithSpeed(t, "5") + + code, output := runPausePointStatusForExpect( + t, []string{"--id", "jump", "--captured-variable-names", "health", "--expect", "speed=5"}) + + if code != 0 { + t.Fatalf("expected success, got %d: %s", code, output) + } + payload := decodePausePointStatusExpectPayload(t, output) + if len(payload.Expectations) != 1 || !payload.Expectations[0].Found || + !payload.Expectations[0].Passed { + t.Fatalf("expectation must survive the name filter: %#v", payload.Expectations) + } +} + +// Verifies a status query without --expect emits neither expectation field, so callers that never +// asked for expectations see no schema change. +func TestRunPausePointStatusOmitsExpectationFieldsWithoutExpectFlag(t *testing.T) { + stubPausePointStatusHitWithSpeed(t, "5") + + code, output := runPausePointStatusForExpect(t, []string{"--id", "jump"}) + + if code != 0 { + t.Fatalf("expected success, got %d: %s", code, output) + } + if strings.Contains(output, "Expectations") || strings.Contains(output, "AllExpectationsPassed") { + t.Fatalf("expectation fields must be omitted without --expect: %s", output) + } +} + +// Verifies a marker that is armed but not yet hit reports its expectations as not found rather than +// omitting them, and that Status stays the field distinguishing "not hit yet" from "hit and wrong". +func TestRunPausePointStatusReportsExpectationsAsNotFoundBeforeAHit(t *testing.T) { + originalQuery := queryPausePointStatus + t.Cleanup(func() { + queryPausePointStatus = originalQuery + }) + queryPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + return pausePointStatusResponse{Success: true, Id: id, Status: pausePointStatusEnabled, IsEnabled: true}, nil + } + + code, output := runPausePointStatusForExpect(t, []string{"--id", "jump", "--expect", "speed=5"}) + + if code != 0 { + t.Fatalf("expected success, got %d: %s", code, output) + } + payload := decodePausePointStatusExpectPayload(t, output) + if payload.Status != pausePointStatusEnabled { + t.Fatalf("Status must still report the marker is only armed: %q", payload.Status) + } + if len(payload.Expectations) != 1 || payload.Expectations[0].Found || payload.Expectations[0].Passed { + t.Fatalf("an unhit marker captured nothing, so the expectation is not found: %#v", payload.Expectations) + } +} + +// Verifies an invalid --expect value is rejected by pause-point-status the same way +// await-pause-point rejects it, instead of being reported as an unknown option. +func TestRunPausePointStatusRejectsInvalidExpectValue(t *testing.T) { + stubPausePointStatusHitWithSpeed(t, "5") + + code, output := runPausePointStatusForExpect(t, []string{"--id", "jump", "--expect", "speed"}) + + if code == 0 { + t.Fatalf("expected failure for an --expect value without '=': %s", output) + } +} + +// Verifies pause-point-status --help advertises --expect, so the flag is discoverable from the +// command that accepts it. +func TestPausePointStatusHelpAdvertisesExpect(t *testing.T) { + var stdout bytes.Buffer + printNativeCommandHelp(clicore.PausePointStatusUserCommandName, &stdout) + + if !strings.Contains(stdout.String(), "--expect") { + t.Fatalf("pause-point-status help must list --expect: %s", stdout.String()) + } +} diff --git a/cli/project-runner/internal/projectrunner/pause_point_trigger.go b/cli/project-runner/internal/projectrunner/pause_point_trigger.go index 49bf1daec5..26d9ebc579 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_trigger.go +++ b/cli/project-runner/internal/projectrunner/pause_point_trigger.go @@ -39,6 +39,42 @@ type pausePointTriggerResult struct { Error string `json:"Error,omitempty"` } +// pausePointTriggerRejectedBeforeExecution reports whether the trigger command was permanently +// rejected before it executed anything: its arguments did not parse, or its command name does not +// exist. Either way the trigger performed no action, so the marker can never be hit by it and +// waiting out the marker's remaining lifetime cannot change the outcome. Retrying the identical +// command reproduces the same rejection, so the value has to change first. +// +// Deliberately narrow. A connection drop, a disabled tool, or unparseable output must not abort the +// wait — the marker may still be hit by the game itself, and abandoning that wait would turn a +// recoverable situation into a lost hit. Anything this function cannot positively identify as a +// pre-execution rejection keeps the wait running. +// +// Only the dispatched command's stderr is inspected, because that is where every error envelope is +// written; a Unity-side rejection arriving on stdout has no error code to match on. +func pausePointTriggerRejectedBeforeExecution(result *pausePointTriggerResult) bool { + if result == nil { + return false + } + + trimmed := bytes.TrimSpace([]byte(result.Error)) + if len(trimmed) == 0 { + return false + } + + envelope := struct { + Error struct { + ErrorCode string `json:"ErrorCode"` + } `json:"Error"` + }{} + if err := json.Unmarshal(trimmed, &envelope); err != nil { + return false + } + + return envelope.Error.ErrorCode == clierrors.ErrorCodeInvalidArgument || + envelope.Error.ErrorCode == clierrors.ErrorCodeUnknownCommand +} + // parsePausePointTriggerCommand splits a --trigger value into a command name and its arguments, // and rejects shapes that cannot behave sensibly when dispatched from inside this CLI process. func parsePausePointTriggerCommand(command string, value string) (string, []string, error) { @@ -46,7 +82,7 @@ func parsePausePointTriggerCommand(command string, value string) (string, []stri if err != nil { return "", nil, &clierrors.ArgumentError{ Message: err.Error(), - Option: "--" + PausePointTriggerFlagName, + Option: "--" + tooldocs.PausePointTriggerFlagName, Command: command, NextActions: []string{ "Quote the trigger command consistently, e.g. --trigger \"simulate-keyboard --action Press --key Space --duration 5\".", @@ -54,7 +90,7 @@ func parsePausePointTriggerCommand(command string, value string) (string, []stri } } if len(tokens) == 0 { - return "", nil, clierrors.MissingValueArgumentError("--" + PausePointTriggerFlagName) + return "", nil, clierrors.MissingValueArgumentError("--" + tooldocs.PausePointTriggerFlagName) } triggerCommand := tokens[0] @@ -67,7 +103,7 @@ func parsePausePointTriggerCommand(command string, value string) (string, []stri Message: fmt.Sprintf( "--trigger cannot target %q: waiting for a pause point from inside another pause-point wait cannot make progress.", triggerCommand), - Option: "--" + PausePointTriggerFlagName, + Option: "--" + tooldocs.PausePointTriggerFlagName, Command: command, NextActions: []string{ "Pass a command that performs an action (for example simulate-keyboard), not another pause-point wait.", @@ -80,7 +116,7 @@ func parsePausePointTriggerCommand(command string, value string) (string, []stri return "", nil, &clierrors.ArgumentError{ Message: "--trigger cannot include --project-path: the triggered command always runs " + "against the same project as the parent command.", - Option: "--" + PausePointTriggerFlagName, + Option: "--" + tooldocs.PausePointTriggerFlagName, Command: command, NextActions: []string{ "Remove --project-path from the trigger command string.", @@ -187,6 +223,16 @@ func startPausePointTrigger( return handle } +// doneChannel exposes the completion channel for the pause-point poll loop's select, so a trigger +// that fails on its own arguments can be observed the moment it reports instead of only at join +// time. Nil-safe: a nil handle yields a nil channel, which a select case never selects. +func (h *pausePointTriggerHandle) doneChannel() <-chan *pausePointTriggerResult { + if h == nil { + return nil + } + return h.done +} + // join waits briefly for the trigger goroutine started by startPausePointTrigger, once the // pause-point wait itself has already settled. The grace window mirrors // pausePointFinalStatusProbeTimeout: a genuine hit interrupts simulate-* commands immediately diff --git a/cli/project-runner/internal/projectrunner/pause_point_trigger_diagnosis.go b/cli/project-runner/internal/projectrunner/pause_point_trigger_diagnosis.go new file mode 100644 index 0000000000..39861450bb --- /dev/null +++ b/cli/project-runner/internal/projectrunner/pause_point_trigger_diagnosis.go @@ -0,0 +1,75 @@ +package projectrunner + +import "encoding/json" + +// pausePointTriggerResponseView is the part of a triggered command's response this diagnosis reads. +// Success is a pointer so a response that omits it is treated as "unknown", not as a failure. +type pausePointTriggerResponseView struct { + Success *bool `json:"Success"` + RejectedByActivePausePointId string `json:"RejectedByActivePausePointId"` +} + +// pausePointTriggerRefusalWarning warns about the case where the marker was hit before the trigger +// ran, so Unity refused the trigger for being called while PlayMode was paused and no input reached +// the game at all. The hit still reports success, which makes this indistinguishable from a real +// input-driven hit unless it is called out. +// +// The refusing pause point's id must match the marker being awaited: a PlayMode paused by some other +// marker is a different problem, and the advice below would be wrong for it. InterruptedByPausePoint +// is deliberately not consulted — it marks the working case, where the marker was hit while the +// input was being applied. +func pausePointTriggerRefusalWarning(result *pausePointTriggerResult, awaitedPausePointID string) string { + response, ok := decodePausePointTriggerResponse(result) + if !ok || response.Success == nil || *response.Success { + return "" + } + if response.RejectedByActivePausePointId != awaitedPausePointID { + return "" + } + + return "The marker was hit before the trigger ran, so Unity refused the trigger for running " + + "while PlayMode was paused and no input reached the game. This hit is not evidence about the " + + "trigger's input: hold the key down with a separate simulate-keyboard KeyDown call before " + + "arming the marker, or move the marker to a line reached after the input is applied. A marker " + + "placed after the input would have paused with the input already in effect." +} + +// pausePointTriggerFailedPointer reports whether the trigger failed, or nil when there is nothing to +// report: no trigger ran, or it never finished so its outcome is unknown. +func pausePointTriggerFailedPointer(result *pausePointTriggerResult) *bool { + if !pausePointTriggerFailed(result) { + return nil + } + failed := true + return &failed +} + +// pausePointTriggerFailed reports whether a completed trigger is known to have failed, either +// because its dispatch failed outright (Error) or because it ran and reported Success:false. +func pausePointTriggerFailed(result *pausePointTriggerResult) bool { + if result == nil || !result.Completed { + return false + } + if result.Error != "" { + return true + } + + response, ok := decodePausePointTriggerResponse(result) + return ok && response.Success != nil && !*response.Success +} + +// decodePausePointTriggerResponse reads the fields this diagnosis needs out of the triggered +// command's raw response, leaving TriggerResult.Response itself untouched. An unfinished trigger or +// an unparseable response yields no view: neither can be diagnosed, and guessing either way is worse +// than reporting nothing. +func decodePausePointTriggerResponse(result *pausePointTriggerResult) (pausePointTriggerResponseView, bool) { + if result == nil || !result.Completed || len(result.Response) == 0 { + return pausePointTriggerResponseView{}, false + } + + view := pausePointTriggerResponseView{} + if err := json.Unmarshal(result.Response, &view); err != nil { + return pausePointTriggerResponseView{}, false + } + return view, true +} diff --git a/cli/project-runner/internal/projectrunner/pause_point_trigger_diagnosis_test.go b/cli/project-runner/internal/projectrunner/pause_point_trigger_diagnosis_test.go new file mode 100644 index 0000000000..178eb8a24a --- /dev/null +++ b/cli/project-runner/internal/projectrunner/pause_point_trigger_diagnosis_test.go @@ -0,0 +1,273 @@ +package projectrunner + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "strings" + "testing" + "time" + + "github.com/hatayama/unity-cli-loop/common/unityipc" +) + +// pausePointRejectedTriggerResponse is the response Unity produces when a simulate command is +// refused before it runs anything because a pause point is holding PlayMode paused. Only Success and +// RejectedByActivePausePointId matter to the diagnosis; the rest is kept for realism. +func pausePointRejectedTriggerResponse(rejectedByID string) string { + return `{"Success":false,` + + `"Message":"PlayMode is paused because pause point '` + rejectedByID + `' is active ...",` + + `"InterruptedByPausePoint":false,"PausePointId":null,` + + `"RejectedByActivePausePointId":"` + rejectedByID + `"}` +} + +func stubPausePointHit(t *testing.T, unityWarning string) { + t.Helper() + + originalQuery := queryPausePointStatus + t.Cleanup(func() { + queryPausePointStatus = originalQuery + }) + + queryPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + return pausePointStatusResponse{ + Success: true, + Id: id, + Status: pausePointStatusHit, + IsHit: true, + HitCount: 1, + Warning: unityWarning, + EditorState: pausePointEditorState{IsPlaying: true, IsPaused: true, CapturedAt: "PausePointHit"}, + }, nil + } +} + +func stubPausePointMatchingLogs(t *testing.T, fetchError error) { + t.Helper() + + originalFetch := fetchMatchingLogs + t.Cleanup(func() { + fetchMatchingLogs = originalFetch + }) + + fetchMatchingLogs = func( + ctx context.Context, + connection unityipc.Connection, + searchText string, + maxCount int, + ) (pausePointMatchingLogsResult, error) { + if fetchError != nil { + return pausePointMatchingLogsResult{}, fetchError + } + return pausePointMatchingLogsResult{ + SearchText: searchText, + TotalCount: 1, + DisplayedCount: 1, + MaxCount: maxCount, + Logs: []pausePointMatchingLog{{Type: "Log", Message: "[jump] hit"}}, + }, nil + } +} + +// stubPausePointTriggerDispatch makes the triggered command produce triggerStdout, which the wait +// path turns into TriggerResult.Response exactly as a real dispatch would. +func stubPausePointTriggerDispatch(t *testing.T, triggerStdout string) { + t.Helper() + + originalDispatch := dispatchPausePointTriggerCommand + t.Cleanup(func() { + dispatchPausePointTriggerCommand = originalDispatch + }) + + dispatchPausePointTriggerCommand = func( + ctx context.Context, + connection unityipc.Connection, + command string, + commandArgs []string, + startPath string, + stdout io.Writer, + stderr io.Writer, + ) int { + _, _ = stdout.Write([]byte(triggerStdout)) + return 0 + } +} + +func runAwaitWithStubbedTrigger(t *testing.T) (int, string) { + t.Helper() + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runWaitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ + id: "jump", + timeoutSeconds: 1, + timeout: time.Second, + matchingLogsMaxCount: 5, + triggerCommand: "simulate-keyboard", + triggerArgs: []string{"--action", "Press", "--key", "W"}, + }, &stdout, &stderr) + if stderr.Len() > 0 { + t.Logf("stderr: %s", stderr.String()) + } + return code, stdout.String() +} + +func decodePausePointWaitResult(t *testing.T, output string) pausePointWaitResult { + t.Helper() + + result := pausePointWaitResult{} + if err := json.Unmarshal([]byte(output), &result); err != nil { + t.Fatalf("stdout parse failed: %v from %s", err, output) + } + return result +} + +// Verifies a trigger refused by the very marker being awaited is called out end to end: the hit +// still reads as a success, so without this the refusal stays buried in TriggerResult.Response. +func TestRunWaitForPausePointWarnsWhenTheTriggerWasRefusedByThisMarker(t *testing.T) { + stubPausePointHit(t, "") + stubPausePointMatchingLogs(t, nil) + stubPausePointTriggerDispatch(t, pausePointRejectedTriggerResponse("jump")) + + code, output := runAwaitWithStubbedTrigger(t) + + if code != 0 { + t.Fatalf("expected the hit to stay a success, got %d: %s", code, output) + } + // Asserted on the raw stdout, not only through the decoded struct: callers read this by its wire + // name, so a renamed json tag must fail here rather than pass a struct round trip. + if !strings.Contains(output, `"TriggerFailed": true`) { + t.Errorf("TriggerFailed must appear at the top level under that exact name: %s", output) + } + result := decodePausePointWaitResult(t, output) + if !strings.Contains(result.Warning, "refused") { + t.Errorf("expected a refusal warning: %q", result.Warning) + } + if result.TriggerFailed == nil || !*result.TriggerFailed { + t.Errorf("TriggerFailed must be promoted to the top level: %#v", result.TriggerFailed) + } +} + +// Verifies the refusal warning still reaches the caller when the matching-log fetch fails, the +// branch that used to build a payload with no Warning field at all. +func TestRunWaitForPausePointKeepsTheRefusalWarningWhenTheLogFetchFails(t *testing.T) { + stubPausePointHit(t, "") + stubPausePointMatchingLogs(t, errors.New("unity busy")) + stubPausePointTriggerDispatch(t, pausePointRejectedTriggerResponse("jump")) + + _, output := runAwaitWithStubbedTrigger(t) + + if strings.Contains(output, `"MatchingLogs"`) { + t.Errorf("a failed fetch must omit MatchingLogs entirely: %s", output) + } + result := decodePausePointWaitResult(t, output) + if !strings.Contains(result.Warning, "refused") { + t.Errorf("expected the refusal warning despite the failed log fetch: %q", result.Warning) + } + if result.TriggerFailed == nil || !*result.TriggerFailed { + t.Errorf("TriggerFailed must survive the failed log fetch: %#v", result.TriggerFailed) + } +} + +// Verifies Unity's own warning survives alongside the CLI's. Both use the JSON name "Warning", where +// the CLI's outer field shadows the embedded Unity one, so Unity's text is lost unless joined in. +func TestRunWaitForPausePointKeepsUnityWarningAlongsideCliWarnings(t *testing.T) { + stubPausePointHit(t, "Unity-side enable warning.") + stubPausePointMatchingLogs(t, nil) + stubPausePointTriggerDispatch(t, pausePointRejectedTriggerResponse("jump")) + + _, output := runAwaitWithStubbedTrigger(t) + + result := decodePausePointWaitResult(t, output) + if !strings.Contains(result.Warning, "Unity-side enable warning.") { + t.Errorf("Unity's warning was dropped: %q", result.Warning) + } + if !strings.Contains(result.Warning, "refused") { + t.Errorf("the CLI warning was dropped: %q", result.Warning) + } +} + +// Verifies the refusal is only blamed on this wait when the refusing marker is the one being +// awaited, so a pause owned by some other marker does not produce advice about this one. +func TestPausePointTriggerRefusalWarningRequiresTheAwaitedMarker(t *testing.T) { + refusedByThisMarker := &pausePointTriggerResult{ + Completed: true, + Response: json.RawMessage(pausePointRejectedTriggerResponse("jump")), + } + if pausePointTriggerRefusalWarning(refusedByThisMarker, "jump") == "" { + t.Error("expected a warning when this marker refused the trigger") + } + + refusedByAnotherMarker := &pausePointTriggerResult{ + Completed: true, + Response: json.RawMessage(pausePointRejectedTriggerResponse("other-marker")), + } + if warning := pausePointTriggerRefusalWarning(refusedByAnotherMarker, "jump"); warning != "" { + t.Errorf("a refusal by another marker must not be warned about here: %q", warning) + } +} + +// Verifies a marker hit while the trigger's input was being applied produces no warning: that is +// the normal, working case, and the earlier draft predicate warned on exactly it. +func TestPausePointTriggerRefusalWarningIgnoresAMidExecutionInterruption(t *testing.T) { + interrupted := &pausePointTriggerResult{ + Completed: true, + Response: json.RawMessage( + `{"Success":true,"InterruptedByPausePoint":true,"PausePointId":"jump"}`), + } + + if warning := pausePointTriggerRefusalWarning(interrupted, "jump"); warning != "" { + t.Errorf("a mid-execution interruption is the normal case: %q", warning) + } +} + +// Verifies a trigger that never reported back within the grace window is neither warned about nor +// called failed: its outcome is unknown, and claiming failure would be as wrong as claiming success. +func TestPausePointTriggerDiagnosisTreatsAnIncompleteTriggerAsUnknown(t *testing.T) { + incomplete := &pausePointTriggerResult{Completed: false} + + if warning := pausePointTriggerRefusalWarning(incomplete, "jump"); warning != "" { + t.Errorf("an unfinished trigger cannot be diagnosed: %q", warning) + } + if pausePointTriggerFailed(incomplete) { + t.Error("an unfinished trigger has no known outcome") + } +} + +// Verifies the two failure shapes a completed trigger can have are both promoted, and a plain +// success is not. +func TestPausePointTriggerFailedCoversBothFailureShapes(t *testing.T) { + failedResponse := &pausePointTriggerResult{ + Completed: true, + Response: json.RawMessage(`{"Success":false,"Message":"no keyboard device found"}`), + } + if !pausePointTriggerFailed(failedResponse) { + t.Error("a completed trigger reporting Success:false has failed") + } + + failedDispatch := &pausePointTriggerResult{ + Completed: true, + Error: `{"Error":{"ErrorCode":"UNITY_NOT_REACHABLE"}}`, + } + if !pausePointTriggerFailed(failedDispatch) { + t.Error("a trigger whose dispatch failed has failed") + } + + succeeded := &pausePointTriggerResult{ + Completed: true, + Response: json.RawMessage(`{"Success":true}`), + } + if pausePointTriggerFailed(succeeded) { + t.Error("a successful trigger must not be reported as failed") + } + + if pausePointTriggerFailed(nil) { + t.Error("no trigger at all cannot have failed") + } +} diff --git a/cli/project-runner/internal/projectrunner/pause_point_types.go b/cli/project-runner/internal/projectrunner/pause_point_types.go index 874ca1174b..4594e41f54 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_types.go +++ b/cli/project-runner/internal/projectrunner/pause_point_types.go @@ -51,6 +51,13 @@ type pausePointStatusResponse struct { // CapturedVariables array for "nothing was captured at this hit". CapturedVariableNameFilterNoMatch bool `json:"CapturedVariableNameFilterNoMatch,omitempty"` + // CapturedVariableNamesNotFound is set by the CLI, not Unity: the requested + // --captured-variable-names that matched no captured variable, in the order they were + // requested. Without it a partial match is indistinguishable from a full one, since the + // response only carries the names that did match and CapturedVariableNameFilterNoMatch covers + // the all-or-nothing case. Both are emitted when nothing matched at all. + CapturedVariableNamesNotFound []string `json:"CapturedVariableNamesNotFound,omitempty"` + // TriggerResult is set by the CLI, not Unity, only when --trigger was passed. It is omitted // entirely otherwise, so callers that never use --trigger see no schema change at all. TriggerResult *pausePointTriggerResult `json:"TriggerResult,omitempty"` @@ -58,6 +65,26 @@ type pausePointStatusResponse struct { // ResumePlayResult is set by the CLI, not Unity, only when --resume-play was passed. It is // omitted entirely otherwise, matching TriggerResult's omit-when-unused contract. ResumePlayResult *pausePointResumePlayResult `json:"ResumePlayResult,omitempty"` + + // TriggerFailed is set by the CLI, not Unity, only when --trigger was passed and the trigger is + // known to have failed. It repeats at the top level what TriggerResult already carries three + // levels down, because the loss it guards against is a caller reading Success:true / Status:Hit + // and never opening TriggerResult at all. A pointer so the field is absent — rather than a + // misleading false — when no trigger ran or its outcome is unknown. + TriggerFailed *bool `json:"TriggerFailed,omitempty"` +} + +// pausePointStatusResult wraps a status response with the CLI-evaluated --expect verdicts. +// pause-point-status marshals the Unity response directly, so it needs this wrapper to carry the +// two extra fields; the names match pausePointWaitResult's so one query shape reads both commands. +type pausePointStatusResult struct { + pausePointStatusResponse + + // Both fields are omitted unless --expect was passed, and AllExpectationsPassed is a pointer + // for the same reason as on pausePointWaitResult: to distinguish "no --expect given" from + // "the given expectations failed". + Expectations []pausePointExpectationResult `json:"Expectations,omitempty"` + AllExpectationsPassed *bool `json:"AllExpectationsPassed,omitempty"` } type pausePointEditorState struct { diff --git a/cli/project-runner/internal/projectrunner/pause_point_unknown_option.go b/cli/project-runner/internal/projectrunner/pause_point_unknown_option.go new file mode 100644 index 0000000000..1ee7fc3c9a --- /dev/null +++ b/cli/project-runner/internal/projectrunner/pause_point_unknown_option.go @@ -0,0 +1,129 @@ +package projectrunner + +import ( + "fmt" + "strings" + + clierrors "github.com/hatayama/unity-cli-loop/common/errors" + + "github.com/hatayama/unity-cli-loop/common/clicore" + "github.com/hatayama/unity-cli-loop/common/tooldocs" +) + +// pausePointFlagOwnerSearchOrder fixes the order in which an unknown flag's owning command is +// resolved. Several pause-point commands accept the same flag name (--id and --timeout-seconds +// exist on more than one), so a map-order search would report a different owner from run to run. +// enable-pause-point comes first because it owns the largest flag set and is the command whose +// flags are most often reached for while inspecting an already-armed marker. +var pausePointFlagOwnerSearchOrder = []string{ + pausePointEnableCommandName, + clicore.PausePointAwaitCommandName, + clicore.PausePointStatusUserCommandName, +} + +// pausePointCarriedOverEnableFlagNames are the enable-pause-point flags whose values Unity reports +// back on every later status response (as Mode, MaxHistory, MaxPreviewElements and TimeoutSeconds). +// Passing one of these to a query command is not just misplaced, it is unnecessary — which is the +// part a caller cannot infer from "wrong command" alone. +var pausePointCarriedOverEnableFlagNames = []string{ + "mode", + "max-history", + "max-preview-elements", + PausePointTimeoutFlagName, +} + +// pausePointUnknownOptionError reports an unrecognized flag for a runner-owned native command. +// A flag that belongs to another pause-point command is reported as such, since naming the real +// owner (and, for enable-time settings, saying the value is already in this response) is what lets +// the caller recover without a second round trip. A flag that exists nowhere is reported as a plain +// unknown option: it cannot be a case of documentation running ahead of this build. +func pausePointUnknownOptionError(command string, name string) *clierrors.ArgumentError { + message := fmt.Sprintf("Unknown option %q for %s.", "--"+name, command) + if owner, ok := pausePointFlagOwnerCommand(name); ok && owner != command { + message = fmt.Sprintf("--%s is %s %s option, not %s %s one.", + name, + indefiniteArticleFor(owner), owner, + indefiniteArticleFor(command), command) + if owner == pausePointEnableCommandName && isPausePointCarriedOverEnableFlag(name) { + message += " The value passed to " + pausePointEnableCommandName + + " is already applied to the response of this command, so it does not need to be passed again here." + } + } + + return &clierrors.ArgumentError{ + Message: message, + Option: "--" + name, + Command: command, + NextActions: []string{fmt.Sprintf("Run `uloop %s --help` to list the accepted options.", command)}, + } +} + +// indefiniteArticleFor picks the article for a command name interpolated into a message. Command +// names are lower-case ASCII identifiers, so the initial letter decides it — "an await-pause-point +// option" rather than "a await-pause-point option". Both slots of the owner sentence go through +// this, so neither reads as broken English for a vowel-initial command. +func indefiniteArticleFor(commandName string) string { + if commandName == "" { + return "a" + } + if strings.ContainsRune("aeiou", rune(commandName[0])) { + return "an" + } + return "a" +} + +// pausePointFlagOwnerCommand reports which pause-point command accepts the flag, searching in a +// fixed order so the answer never depends on map iteration. +func pausePointFlagOwnerCommand(name string) (string, bool) { + for _, command := range pausePointFlagOwnerSearchOrder { + for _, flagName := range pausePointCommandFlagNames(command) { + if flagName == name { + return command, true + } + } + } + return "", false +} + +// pausePointCommandFlagNames lists the flag names a pause-point command accepts, without the "--" +// prefix. Both sources are the same tables the commands' own --help output is built from, so a flag +// added to a command becomes recognizable here without a second registration. +func pausePointCommandFlagNames(command string) []string { + if command == pausePointEnableCommandName { + return pausePointEnableFlagNames() + } + + names := make([]string, 0, len(runnerNativeCommandOptions[command])) + for _, option := range runnerNativeCommandOptions[command] { + names = append(names, strings.TrimPrefix(option, "--")) + } + return names +} + +// pausePointEnableFlagNames lists enable-pause-point's CLI-only flags plus the ones derived from its +// Unity schema, since the misuse this message exists for (--max-preview-elements on a query command) +// is a schema-derived flag. +func pausePointEnableFlagNames() []string { + names := make([]string, 0) + for _, option := range tooldocs.PausePointEnableCLIOnlyOptions() { + names = append(names, option.FlagName) + } + + tool, ok := clicore.FindTool(clicore.LoadDefaultTools(), pausePointEnableCommandName) + if !ok { + return names + } + for propertyName, property := range tool.EffectiveInputSchema().Properties { + names = append(names, tooldocs.OptionNameForProperty(tool.Name, propertyName, property)) + } + return names +} + +func isPausePointCarriedOverEnableFlag(name string) bool { + for _, flagName := range pausePointCarriedOverEnableFlagNames { + if flagName == name { + return true + } + } + return false +} diff --git a/cli/project-runner/internal/projectrunner/pause_point_unknown_option_test.go b/cli/project-runner/internal/projectrunner/pause_point_unknown_option_test.go new file mode 100644 index 0000000000..21289b5a5a --- /dev/null +++ b/cli/project-runner/internal/projectrunner/pause_point_unknown_option_test.go @@ -0,0 +1,144 @@ +package projectrunner + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/hatayama/unity-cli-loop/common/clicore" +) + +// Verifies a flag that belongs to enable-pause-point names its real owner and states that the value +// given at enable time already shows up here, which is the round trip the original message cost: +// the flag exists, so "your runner may be outdated" was never the answer. +func TestParsePausePointStatusUnknownOptionNamesEnableAsTheOwner(t *testing.T) { + _, err := parsePausePointStatusOptions([]string{"--id", "jump", "--max-preview-elements", "5"}) + + if err == nil { + t.Fatal("expected error for an enable-pause-point flag passed to pause-point-status") + } + message := err.Error() + if !strings.Contains(message, "--max-preview-elements is an enable-pause-point option, not a pause-point-status one.") { + t.Errorf("owner sentence missing: %s", message) + } + if !strings.Contains( + message, + "The value passed to enable-pause-point is already applied to the response of this command, "+ + "so it does not need to be passed again here.") { + t.Errorf("carry-over sentence missing: %s", message) + } + if strings.Contains(message, "older than the docs") { + t.Errorf("stale-runner hint must be gone: %s", message) + } +} + +// Verifies an enable-pause-point flag whose value is not carried into this command's response names +// the owner without claiming a carry-over that does not happen. +func TestParsePausePointStatusUnknownOptionOmitsCarryOverForNonCarriedFlags(t *testing.T) { + _, err := parsePausePointStatusOptions([]string{"--id", "jump", "--line", "42"}) + + if err == nil { + t.Fatal("expected error for --line passed to pause-point-status") + } + message := err.Error() + if !strings.Contains(message, "--line is an enable-pause-point option, not a pause-point-status one.") { + t.Errorf("owner sentence missing: %s", message) + } + if strings.Contains(message, "already applied to the response") { + t.Errorf("carry-over sentence must not be claimed for --line: %s", message) + } +} + +// Verifies a flag owned by another runner-owned command names that command, so a flag borrowed from +// await-pause-point is not reported as an enable-pause-point one. +func TestParsePausePointStatusUnknownOptionNamesAwaitAsTheOwner(t *testing.T) { + _, err := parsePausePointStatusOptions( + []string{"--id", "jump", "--" + PausePointLogsMaxCountFlagName, "3"}) + + if err == nil { + t.Fatal("expected error for an await-pause-point flag passed to pause-point-status") + } + if !strings.Contains( + err.Error(), + "--"+PausePointLogsMaxCountFlagName+" is an await-pause-point option, not a pause-point-status one.") { + t.Errorf("owner sentence missing: %s", err.Error()) + } +} + +// Verifies the article agrees with the command name in both slots of the owner sentence, so a +// vowel-initial command such as await-pause-point does not produce "a await-pause-point one". +func TestPausePointUnknownOptionArticleAgreesWithTheCommandName(t *testing.T) { + _, err := parseWaitForPausePointOptions( + []string{"--id", "jump", "--max-preview-elements", "5"}) + + if err == nil { + t.Fatal("expected error for an enable-pause-point flag passed to await-pause-point") + } + if !strings.Contains( + err.Error(), + "--max-preview-elements is an enable-pause-point option, not an await-pause-point one.") { + t.Errorf("article mismatch in the owner sentence: %s", err.Error()) + } + + if article := indefiniteArticleFor(clicore.PausePointStatusUserCommandName); article != "a" { + t.Errorf("a consonant-initial command takes \"a\", got %q", article) + } +} + +// Verifies the owner reported for a flag several commands accept is fixed rather than dependent on +// map iteration order, so the same misuse always produces the same message. +func TestPausePointUnknownOptionOwnerIsDeterministic(t *testing.T) { + const flagName = PausePointTimeoutFlagName + + first, ok := pausePointFlagOwnerCommand(flagName) + if !ok { + t.Fatalf("--%s must resolve to an owning command", flagName) + } + if first != pausePointEnableCommandName { + t.Errorf("owner of --%s must be the first command in the fixed search order, got %q", flagName, first) + } + for attempt := 0; attempt < 20; attempt++ { + owner, _ := pausePointFlagOwnerCommand(flagName) + if owner != first { + t.Fatalf("owner of --%s changed between calls: %q then %q", flagName, first, owner) + } + } +} + +// Verifies the carry-over sentence is only claimed for the enable-time settings the status response +// actually reports back, so the message never promises evidence the response cannot show. +func TestPausePointCarriedOverEnableFlagsAreVisibleInTheStatusResponse(t *testing.T) { + response, err := json.Marshal(pausePointStatusResponse{ + Mode: "continuous", + MaxHistory: 20, + MaxPreviewElements: 5, + TimeoutSeconds: 30, + }) + if err != nil { + t.Fatalf("failed to marshal status response: %v", err) + } + + carriedOverFields := map[string]string{ + "mode": "Mode", + "max-history": "MaxHistory", + "max-preview-elements": "MaxPreviewElements", + "timeout-seconds": "TimeoutSeconds", + } + if len(carriedOverFields) != len(pausePointCarriedOverEnableFlagNames) { + t.Fatalf("carry-over flag list changed: %v", pausePointCarriedOverEnableFlagNames) + } + for _, flagName := range pausePointCarriedOverEnableFlagNames { + field, ok := carriedOverFields[flagName] + if !ok { + t.Errorf("--%s is described as carried over but has no known status response field", flagName) + continue + } + if !strings.Contains(string(response), `"`+field+`"`) { + t.Errorf("status response does not report %s for --%s", field, flagName) + } + if owner, ok := pausePointFlagOwnerCommand(flagName); !ok || owner != pausePointEnableCommandName { + t.Errorf("--%s is listed as a carried-over enable flag but resolves to owner %q (found=%v)", + flagName, owner, ok) + } + } +} diff --git a/cli/project-runner/internal/projectrunner/pause_point_wait.go b/cli/project-runner/internal/projectrunner/pause_point_wait.go index e1f055ec7a..e8f29efc75 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_wait.go +++ b/cli/project-runner/internal/projectrunner/pause_point_wait.go @@ -11,6 +11,7 @@ import ( clierrors "github.com/hatayama/unity-cli-loop/common/errors" "github.com/hatayama/unity-cli-loop/common/clicore" + "github.com/hatayama/unity-cli-loop/common/tooldocs" "github.com/hatayama/unity-cli-loop/common/unityipc" ) @@ -60,6 +61,7 @@ type pausePointStatusOptions struct { id string capturedVariablesMode pausePointCapturedVariablesMode capturedVariableNames []string + expectations []pausePointExpectation } func normalizePausePointStatusResponse(response pausePointStatusResponse) pausePointStatusResponse { @@ -170,10 +172,21 @@ func runPausePointStatusCommand( } response = normalizePausePointStatusResponse(response) response = filterPausePointCapturedVariableHistory(response) + // Evaluated against the raw CapturedVariables, before the filters below can narrow or strip + // values, for the same reason as on the await path (runWaitForPausePoint): otherwise an --expect + // target not also requested via --captured-variable-names, or whose value names mode stripped, + // would be reported as missing or failing. + expectations := evaluatePausePointExpectations(response.CapturedVariables, options.expectations) response = filterPausePointCapturedVariablesByName(response, options.capturedVariableNames) response = applyPausePointCapturedVariablesMode(response, options.capturedVariablesMode) - result, err := json.Marshal(response) + // Expectation verdicts never change the exit code: whether the query succeeded and whether the + // captured state matched are separate questions, as on await-pause-point. + result, err := json.Marshal(pausePointStatusResult{ + pausePointStatusResponse: response, + Expectations: expectations, + AllExpectationsPassed: pausePointAllExpectationsPassedPointer(expectations), + }) if err != nil { clierrors.WriteClassifiedError(stderr, err, clierrors.ErrorContext{ ProjectRoot: connection.ProjectRoot, @@ -217,35 +230,16 @@ func runWaitForPausePoint( response = filterPausePointCapturedVariablesByName(response, options.capturedVariableNames) response = applyPausePointCapturedVariablesMode(response, options.capturedVariablesMode) // Best-effort: a hit must stay a success even if Unity is busy while paused. - // On fetch failure MatchingLogs is omitted entirely, so an empty array always - // means "the fetch succeeded and no matching log exists". - var payload any = response logs, logsErr := fetchMatchingLogs(ctx, connection, options.id, options.matchingLogsMaxCount) - switch { - case logsErr == nil: - payload = pausePointWaitResult{ - pausePointStatusResponse: response, - MatchingLogs: logs.Logs, - Warning: buildPausePointWarning(logs, response.HitCount), - Expectations: expectations, - AllExpectationsPassed: pausePointAllExpectationsPassedPointer(expectations), - } - case len(expectations) > 0: - // Best-effort: a failed log fetch must not also drop --expect results, since that is - // the only evidence a caller asked for by name in this branch. Uses an anonymous - // struct (not pausePointWaitResult) so MatchingLogs is omitted entirely rather than - // serialized as an empty array, preserving "empty array only means a successful - // fetch with no matches". - payload = struct { - pausePointStatusResponse - Expectations []pausePointExpectationResult `json:"Expectations,omitempty"` - AllExpectationsPassed *bool `json:"AllExpectationsPassed,omitempty"` - }{ - pausePointStatusResponse: response, - Expectations: expectations, - AllExpectationsPassed: pausePointAllExpectationsPassedPointer(expectations), - } - } + payload := buildPausePointHitPayload(pausePointHitPayloadInputs{ + response: response, + logs: logs, + logsErr: logsErr, + unityWarning: response.Warning, + triggerResult: triggerResult, + awaitedPausePointID: options.id, + expectations: expectations, + }) result, marshalErr := json.Marshal(payload) if marshalErr != nil { clierrors.WriteClassifiedError(stderr, marshalErr, clierrors.ErrorContext{ @@ -297,7 +291,7 @@ func parseWaitForPausePointOptions(args []string) (waitForPausePointOptions, err arg := args[index] // --resume-play is a boolean flag (no value). ParseFlagValue would otherwise demand one. - if arg == "--"+PausePointResumePlayFlagName { + if arg == "--"+tooldocs.PausePointResumePlayFlagName { options.resumePlay = true continue } @@ -324,33 +318,33 @@ func parseWaitForPausePointOptions(args []string) (waitForPausePointOptions, err "--"+PausePointLogsMaxCountFlagName, value, "positive integer") } options.matchingLogsMaxCount = maxCount - case PausePointCapturedVariablesFlagName: + case tooldocs.PausePointCapturedVariablesFlagName: mode, parseErr := parsePausePointCapturedVariablesMode(value) if parseErr != nil { return waitForPausePointOptions{}, parseErr } options.capturedVariablesMode = mode - case PausePointCapturedVariableNamesFlagName: + case tooldocs.PausePointCapturedVariableNamesFlagName: options.capturedVariableNames = parsePausePointCapturedVariableNames(value) - case PausePointExpectFlagName: + case tooldocs.PausePointExpectFlagName: expectation, parseErr := parsePausePointExpectFlagValue(value) if parseErr != nil { return waitForPausePointOptions{}, parseErr } options.expectations = append(options.expectations, expectation) - case PausePointTriggerFlagName: + case tooldocs.PausePointTriggerFlagName: triggerCommand, triggerArgs, parseErr := parsePausePointTriggerCommand(clicore.PausePointAwaitCommandName, value) if parseErr != nil { return waitForPausePointOptions{}, parseErr } options.triggerCommand = triggerCommand options.triggerArgs = triggerArgs - case PausePointResumePlayFlagName: + case tooldocs.PausePointResumePlayFlagName: // --resume-play=true style is accepted; any other value is rejected so a typo cannot // silently disable the resume step. if value != "true" && value != "1" { return waitForPausePointOptions{}, clierrors.InvalidValueArgumentError( - "--"+PausePointResumePlayFlagName, value, "boolean flag (pass with no value)") + "--"+tooldocs.PausePointResumePlayFlagName, value, "boolean flag (pass with no value)") } options.resumePlay = true default: @@ -388,14 +382,20 @@ func parsePausePointStatusOptions(args []string) (pausePointStatusOptions, error switch name { case PausePointIDFlagName: options.id = value - case PausePointCapturedVariablesFlagName: + case tooldocs.PausePointCapturedVariablesFlagName: mode, parseErr := parsePausePointCapturedVariablesMode(value) if parseErr != nil { return pausePointStatusOptions{}, parseErr } options.capturedVariablesMode = mode - case PausePointCapturedVariableNamesFlagName: + case tooldocs.PausePointCapturedVariableNamesFlagName: options.capturedVariableNames = parsePausePointCapturedVariableNames(value) + case tooldocs.PausePointExpectFlagName: + expectation, parseErr := parsePausePointExpectFlagValue(value) + if parseErr != nil { + return pausePointStatusOptions{}, parseErr + } + options.expectations = append(options.expectations, expectation) default: return pausePointStatusOptions{}, pausePointUnknownOptionError(clicore.PausePointStatusUserCommandName, name) } diff --git a/cli/project-runner/internal/projectrunner/pause_point_wait_poll.go b/cli/project-runner/internal/projectrunner/pause_point_wait_poll.go index f00e8b4ab5..e9da76523e 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_wait_poll.go +++ b/cli/project-runner/internal/projectrunner/pause_point_wait_poll.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + clierrors "github.com/hatayama/unity-cli-loop/common/errors" "github.com/hatayama/unity-cli-loop/common/unityipc" ) @@ -20,6 +21,14 @@ const ( pausePointWaitStateNotEnabled pausePointWaitState = "not_enabled" pausePointWaitStateExpired pausePointWaitState = "expired" pausePointWaitStateCleared pausePointWaitState = "cleared" + + // pausePointWaitStateTriggerFailed means the wait was abandoned because the --trigger command was + // rejected before it executed anything, so it never performed the action the marker was waiting for. + // Why not reuse the timeout state: the timeout path unconditionally clears the marker, which + // would force a re-enable just to retry a corrected trigger, and it reports a "not hit within + // %ds" message with a PlayMode diagnosis that describes neither what happened nor what to fix. + // Internal to this package — it never appears on the wire. + pausePointWaitStateTriggerFailed pausePointWaitState = "trigger_failed" ) // waitForPausePoint confirms the marker is actually armed with one status query before starting @@ -34,60 +43,88 @@ func waitForPausePoint( connection unityipc.Connection, options waitForPausePointOptions, ) (pausePointStatusResponse, pausePointWaitState, *pausePointTriggerResult, *pausePointResumePlayResult, error) { - var triggerHandle *pausePointTriggerHandle - var skippedTriggerResult *pausePointTriggerResult - var resumeResult *pausePointResumePlayResult + triggerHandle, skippedTriggerResult, resumeResult := startPausePointWaitSideEffects(ctx, connection, options) - if options.triggerCommand != "" || options.resumePlay { - if pausePointIsArmed(ctx, connection, options.id) { - if options.resumePlay { - result := resumePlayModeForPausePoint(ctx, connection) - resumeResult = &result - if result.Error != "" { - if options.triggerCommand != "" { - skippedTriggerResult = &pausePointTriggerResult{ - Command: pausePointTriggerCommandString(options.triggerCommand, options.triggerArgs), - Error: "trigger was not dispatched: --resume-play failed to resume play mode", - } - } - } else if options.triggerCommand != "" { - triggerHandle = startPausePointTrigger(ctx, connection, options.startPath, options.triggerCommand, options.triggerArgs) - } - } else if options.triggerCommand != "" { - triggerHandle = startPausePointTrigger(ctx, connection, options.startPath, options.triggerCommand, options.triggerArgs) - } - } else { - if options.resumePlay { - // --resume-play always yields a ResumePlayResult when given, even when skipped: a - // silently omitted result would hide "arm was never confirmed" behind a plain - // timeout/not-enabled error with no clue why Play was never resumed. - resumeResult = &pausePointResumePlayResult{ - Error: "resume was not dispatched: the marker could not be confirmed armed at wait start", - } - } - if options.triggerCommand != "" { - // --trigger always yields a TriggerResult when given, even when skipped: a silently - // omitted TriggerResult would be indistinguishable from "the trigger ran but this CLI - // exited before it reported anything," hiding the real reason (marker not confirmed - // armed) behind a plain timeout/not-enabled error with no clue why the trigger never fired. - skippedTriggerResult = &pausePointTriggerResult{ - Command: pausePointTriggerCommandString(options.triggerCommand, options.triggerArgs), - Error: "trigger was not dispatched: the marker could not be confirmed armed at wait start", - } - } - } + response, state, polledTriggerResult, err := waitForPausePointStatus(ctx, connection, options, triggerHandle) + + if state == pausePointWaitStateTriggerFailed && resumeResult != nil && resumeResult.Resumed { + repaused := repausePlayModeAfterAbandonedWait(ctx, connection, *resumeResult) + resumeResult = &repaused } - response, state, err := waitForPausePointStatus(ctx, connection, options) if skippedTriggerResult != nil { return response, state, skippedTriggerResult, resumeResult, err } + // The trigger goroutine's buffered channel yields exactly one value, so a result already + // received by the poll loop must be reused here instead of joining again — a second receive + // would block for the whole grace window and then report Completed:false over a real result. + if polledTriggerResult != nil { + return response, state, polledTriggerResult, resumeResult, err + } if triggerHandle != nil { return response, state, triggerHandle.join(), resumeResult, err } return response, state, nil, resumeResult, err } +// startPausePointWaitSideEffects performs the pre-wait --resume-play / --trigger work, returning +// either a live trigger handle or the fixed TriggerResult explaining why the trigger was skipped. +func startPausePointWaitSideEffects( + ctx context.Context, + connection unityipc.Connection, + options waitForPausePointOptions, +) (*pausePointTriggerHandle, *pausePointTriggerResult, *pausePointResumePlayResult) { + if options.triggerCommand == "" && !options.resumePlay { + return nil, nil, nil + } + + if !pausePointIsArmed(ctx, connection, options.id) { + var resumeResult *pausePointResumePlayResult + var skippedTriggerResult *pausePointTriggerResult + if options.resumePlay { + // --resume-play always yields a ResumePlayResult when given, even when skipped: a + // silently omitted result would hide "arm was never confirmed" behind a plain + // timeout/not-enabled error with no clue why Play was never resumed. + resumeResult = &pausePointResumePlayResult{ + Error: "resume was not dispatched: the marker could not be confirmed armed at wait start", + } + } + if options.triggerCommand != "" { + // --trigger always yields a TriggerResult when given, even when skipped: a silently + // omitted TriggerResult would be indistinguishable from "the trigger ran but this CLI + // exited before it reported anything," hiding the real reason (marker not confirmed + // armed) behind a plain timeout/not-enabled error with no clue why the trigger never fired. + skippedTriggerResult = &pausePointTriggerResult{ + Command: pausePointTriggerCommandString(options.triggerCommand, options.triggerArgs), + Error: "trigger was not dispatched: the marker could not be confirmed armed at wait start", + } + } + return nil, skippedTriggerResult, resumeResult + } + + var resumeResult *pausePointResumePlayResult + if options.resumePlay { + result := resumePlayModeForPausePoint(ctx, connection) + resumeResult = &result + if result.Error != "" { + if options.triggerCommand == "" { + return nil, nil, resumeResult + } + return nil, &pausePointTriggerResult{ + Command: pausePointTriggerCommandString(options.triggerCommand, options.triggerArgs), + Error: "trigger was not dispatched: --resume-play failed to resume play mode", + }, resumeResult + } + } + + if options.triggerCommand == "" { + return nil, nil, resumeResult + } + + handle := startPausePointTrigger(ctx, connection, options.startPath, options.triggerCommand, options.triggerArgs) + return handle, nil, resumeResult +} + // pausePointIsArmed reports whether the marker is enabled or already hit. A query failure is // treated as not armed: dispatching a --trigger command against a marker this CLI cannot even // confirm exists would inject the trigger's action into the game with no corresponding wait. @@ -104,13 +141,16 @@ func waitForPausePointStatus( ctx context.Context, connection unityipc.Connection, options waitForPausePointOptions, -) (pausePointStatusResponse, pausePointWaitState, error) { + triggerHandle *pausePointTriggerHandle, +) (pausePointStatusResponse, pausePointWaitState, *pausePointTriggerResult, error) { waitContext, cancel := context.WithTimeout(ctx, options.timeout) defer cancel() lastResponse := pausePointStatusResponse{Id: options.id} var lastErr error + var triggerResult *pausePointTriggerResult hasResponse := false + triggerDone := triggerHandle.doneChannel() ticker := time.NewTicker(pausePointStatusPoll) defer ticker.Stop() for { @@ -120,39 +160,74 @@ func waitForPausePointStatus( hasResponse = true state := pausePointWaitStateForStatus(response.Status) if state != "" { - return response, state, nil + return response, state, triggerResult, nil } } else { + // Why abort: every poll dials again, so a connect the operating system refused + // permanently keeps failing for the whole --timeout and the refusal is reported only + // after that wait is spent. + if clierrors.IsPermanentConnectError(err) { + return lastResponse, "", triggerResult, err + } lastErr = err } select { case <-waitContext.Done(): if ctx.Err() != nil { - return lastResponse, "", ctx.Err() + return lastResponse, "", triggerResult, ctx.Err() } finalResponse, finalState, hasFinalResponse, finalErr := queryPausePointStatusAtTimeout(ctx, connection, options.id) if hasFinalResponse { lastResponse = finalResponse hasResponse = true if finalState != "" { - return finalResponse, finalState, nil + return finalResponse, finalState, triggerResult, nil } } else if lastErr == nil { lastErr = finalErr } if hasResponse { - return lastResponse, pausePointWaitStateTimeout, nil + return lastResponse, pausePointWaitStateTimeout, triggerResult, nil } if lastErr != nil { - return lastResponse, "", fmt.Errorf("timed out waiting for pause point status: %w", lastErr) + return lastResponse, "", triggerResult, fmt.Errorf("timed out waiting for pause point status: %w", lastErr) + } + return lastResponse, pausePointWaitStateTimeout, triggerResult, nil + case result := <-triggerDone: + // Nil the channel so this case can never fire twice: the handle's channel holds a single + // buffered value, and the caller reuses the result received here instead of joining. + triggerResult = result + triggerDone = nil + if pausePointTriggerRejectedBeforeExecution(result) { + abortResponse, abortState := abortPausePointWaitAfterTriggerRejection( + ctx, connection, options.id, lastResponse) + return abortResponse, abortState, triggerResult, nil } - return lastResponse, pausePointWaitStateTimeout, nil case <-ticker.C: } } } +// abortPausePointWaitAfterTriggerRejection takes one last status reading before abandoning the +// wait, because a trigger rejection can race a genuine hit produced by the game itself: without +// this the hit would be discarded and reported as a trigger failure. +func abortPausePointWaitAfterTriggerRejection( + ctx context.Context, + connection unityipc.Connection, + id string, + lastResponse pausePointStatusResponse, +) (pausePointStatusResponse, pausePointWaitState) { + response, state, hasResponse, _ := queryPausePointStatusAtTimeout(ctx, connection, id) + if !hasResponse { + return lastResponse, pausePointWaitStateTriggerFailed + } + if state != "" { + return response, state + } + return response, pausePointWaitStateTriggerFailed +} + func queryPausePointStatusAtTimeout( ctx context.Context, connection unityipc.Connection, diff --git a/cli/project-runner/internal/projectrunner/pause_point_wait_poll_test.go b/cli/project-runner/internal/projectrunner/pause_point_wait_poll_test.go new file mode 100644 index 0000000000..6882040ca0 --- /dev/null +++ b/cli/project-runner/internal/projectrunner/pause_point_wait_poll_test.go @@ -0,0 +1,814 @@ +package projectrunner + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net" + "os" + "strings" + "syscall" + "testing" + "time" + + clierrors "github.com/hatayama/unity-cli-loop/common/errors" + + "github.com/hatayama/unity-cli-loop/common/unityipc" +) + +// argumentErrorTriggerStderr is the error envelope a trigger command writes when its own argument +// parsing rejects a value, which is exactly the case that must abort the wait instead of letting +// the marker's full --timeout-seconds elapse. +const argumentErrorTriggerStderr = `{"Success":false,"Error":{"ErrorCode":"INVALID_ARGUMENT",` + + `"Phase":"argument_parsing","Message":"Invalid value for --action: \"Hold\"","Retryable":false,` + + `"SafeToRetry":false,"NextActions":["Pass one of: Press, KeyDown, KeyUp, ReleaseAll."]}}` + +// unknownCommandTriggerStderr is the error envelope a mistyped trigger command name produces. It +// never reaches Unity at all, so it is as permanent a rejection as a bad argument value. +const unknownCommandTriggerStderr = `{"Success":false,"Error":{"ErrorCode":"UNKNOWN_COMMAND",` + + `"Phase":"dispatch","Message":"Unknown command: simulate-keybord","Retryable":false,` + + `"SafeToRetry":false,"NextActions":["Run ` + "`uloop list`" + ` to see the available commands."]}}` + +// pausePointArmedStatusResponse is an armed-but-never-hit marker with a lifetime long enough that +// RemainingMilliseconds stays positive, so the abort response can be asserted to carry it. +func pausePointArmedStatusResponse(id string) pausePointStatusResponse { + return pausePointStatusResponse{ + Id: id, + Status: pausePointStatusEnabled, + IsEnabled: true, + TimeoutSeconds: 60, + ElapsedSinceEnabledMilliseconds: 1_000, + EditorState: pausePointEditorState{IsPlaying: true, CapturedAt: "Current"}, + } +} + +// Verifies a connect the operating system refused permanently aborts the wait at the first poll. +// Every poll dials again, so without the abort the refusal is reported only after the whole +// --timeout has been spent on an error that cannot clear. +func TestWaitForPausePointAbortsWhenTheConnectIsRefused(t *testing.T) { + originalQuery := queryPausePointStatus + originalPoll := pausePointStatusPoll + pausePointStatusPoll = time.Millisecond + defer func() { + queryPausePointStatus = originalQuery + pausePointStatusPoll = originalPoll + }() + + refusedConnect := &unityipc.ConnectionAttemptError{ + Endpoint: "/tmp/uloop-501/UnityCliLoop-sample.sock", + Cause: &net.OpError{ + Op: "dial", + Net: "unix", + Addr: &net.UnixAddr{Name: "/tmp/uloop-501/UnityCliLoop-sample.sock", Net: "unix"}, + Err: os.NewSyscallError("connect", syscall.EPERM), + }, + } + queryCount := 0 + queryPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + queryCount++ + return pausePointStatusResponse{}, fmt.Errorf("pause point status query failed: %w", refusedConnect) + } + + startedAt := time.Now() + _, _, _, _, err := waitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ + id: "jump", + timeoutSeconds: 60, + timeout: 10 * time.Second, + }) + elapsed := time.Since(startedAt) + + if !errors.Is(err, refusedConnect) { + t.Fatalf("expected the refused connect error, got %v", err) + } + if queryCount != 1 { + t.Fatalf("expected the wait to stop after the first poll, got %d polls", queryCount) + } + if elapsed >= 5*time.Second { + t.Fatalf("expected an early abort, waited %v of a 10s timeout", elapsed) + } +} + +// Verifies a --trigger rejected by its own argument parsing aborts the wait immediately, instead of +// waiting out the marker's --timeout-seconds, and reports the rejection on TriggerResult. +func TestWaitForPausePointAbortsWhenTriggerRejectsArguments(t *testing.T) { + originalQuery := queryPausePointStatus + originalDispatch := dispatchPausePointTriggerCommand + originalPoll := pausePointStatusPoll + pausePointStatusPoll = time.Millisecond + defer func() { + queryPausePointStatus = originalQuery + dispatchPausePointTriggerCommand = originalDispatch + pausePointStatusPoll = originalPoll + }() + + queryPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + return pausePointArmedStatusResponse(id), nil + } + dispatchPausePointTriggerCommand = func( + ctx context.Context, + connection unityipc.Connection, + command string, + commandArgs []string, + startPath string, + stdout io.Writer, + stderr io.Writer, + ) int { + _, _ = stderr.Write([]byte(argumentErrorTriggerStderr)) + return 1 + } + + startedAt := time.Now() + _, state, triggerResult, _, err := waitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ + id: "jump", + timeoutSeconds: 60, + timeout: 10 * time.Second, + triggerCommand: "simulate-keyboard", + triggerArgs: []string{"--action", "Hold", "--key", "A"}, + }) + elapsed := time.Since(startedAt) + + if err != nil { + t.Fatalf("waitForPausePoint failed: %v", err) + } + if state != pausePointWaitStateTriggerFailed { + t.Fatalf("expected trigger_failed state, got %q", state) + } + if elapsed >= 5*time.Second { + t.Fatalf("expected an early abort, waited %v of a 10s timeout", elapsed) + } + if triggerResult == nil { + t.Fatal("expected a TriggerResult reporting the rejection, got nil") + } + if !strings.Contains(triggerResult.Error, "INVALID_ARGUMENT") { + t.Fatalf("expected the trigger's own rejection in Error, got %#v", triggerResult) + } + if triggerResult.Command != "simulate-keyboard --action Hold --key A" { + t.Fatalf("TriggerResult command mismatch: %#v", triggerResult) + } +} + +// Verifies the abort keeps the marker armed (no clear) and reports a dedicated error code with the +// marker's remaining lifetime and a copy-pasteable recovery command, rather than reusing the +// timeout error's message and PlayMode diagnosis. +func TestRunWaitForPausePointKeepsMarkerWhenTriggerRejectsArguments(t *testing.T) { + originalQuery := queryPausePointStatus + originalClear := clearPausePointStatus + originalDispatch := dispatchPausePointTriggerCommand + originalPoll := pausePointStatusPoll + pausePointStatusPoll = time.Millisecond + defer func() { + queryPausePointStatus = originalQuery + clearPausePointStatus = originalClear + dispatchPausePointTriggerCommand = originalDispatch + pausePointStatusPoll = originalPoll + }() + + queryPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + return pausePointArmedStatusResponse(id), nil + } + clearCalled := false + clearPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + clearCalled = true + return pausePointStatusResponse{Id: id, Status: pausePointStatusCleared}, nil + } + dispatchPausePointTriggerCommand = func( + ctx context.Context, + connection unityipc.Connection, + command string, + commandArgs []string, + startPath string, + stdout io.Writer, + stderr io.Writer, + ) int { + _, _ = stderr.Write([]byte(argumentErrorTriggerStderr)) + return 1 + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runWaitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ + id: "jump", + timeoutSeconds: 60, + timeout: 10 * time.Second, + triggerCommand: "simulate-keyboard", + triggerArgs: []string{"--action", "Hold", "--key", "A"}, + }, &stdout, &stderr) + + if code != 1 { + t.Fatalf("expected failure exit code, got %d with stdout %s", code, stdout.String()) + } + if clearCalled { + t.Fatal("expected the marker to stay armed: clear must not be called when the trigger was rejected") + } + + envelope := parsePausePointErrorEnvelope(t, stderr.Bytes()) + if envelope.Error.ErrorCode != clierrors.ErrorCodePausePointTriggerFailed { + t.Fatalf("error code mismatch: %#v", envelope.Error) + } + if envelope.Error.Retryable || envelope.Error.SafeToRetry { + t.Fatalf("a rejected trigger is not fixed by retrying the same command: %#v", envelope.Error) + } + if strings.Contains(envelope.Error.Message, "was not hit within") { + t.Fatalf("the timeout message must not be reused for an aborted wait: %#v", envelope.Error) + } + if _, hasHint := envelope.Error.Details["Hint"]; hasHint { + t.Fatalf("the timeout PlayMode diagnosis must not be attached to an aborted wait: %#v", envelope.Error.Details) + } + triggerResult, ok := envelope.Error.Details["TriggerResult"].(map[string]any) + if !ok { + t.Fatalf("TriggerResult detail missing or wrong shape: %#v", envelope.Error.Details) + } + if errorText, _ := triggerResult["Error"].(string); !strings.Contains(errorText, "INVALID_ARGUMENT") { + t.Fatalf("TriggerResult must carry the trigger's own rejection: %#v", triggerResult) + } + remaining, ok := envelope.Error.Details["RemainingMilliseconds"].(float64) + if !ok || remaining <= 0 { + t.Fatalf("expected a positive RemainingMilliseconds for the preserved marker: %#v", envelope.Error.Details) + } + + nextActions := strings.Join(envelope.Error.NextActions, "\n") + if !strings.Contains(nextActions, "--trigger") { + t.Fatalf("recovery guidance must point at the --trigger value to fix: %#v", envelope.Error.NextActions) + } + if !strings.Contains(nextActions, `uloop await-pause-point --id "jump"`) { + t.Fatalf("expected a copy-pasteable await command carrying the real id: %#v", envelope.Error.NextActions) + } +} + +// Verifies a mistyped trigger command name aborts the wait and keeps the marker armed, the same as +// a rejected argument value: the command never reached Unity, so the marker cannot be hit by it. +func TestRunWaitForPausePointKeepsMarkerWhenTriggerCommandIsUnknown(t *testing.T) { + originalQuery := queryPausePointStatus + originalClear := clearPausePointStatus + originalDispatch := dispatchPausePointTriggerCommand + originalPoll := pausePointStatusPoll + pausePointStatusPoll = time.Millisecond + defer func() { + queryPausePointStatus = originalQuery + clearPausePointStatus = originalClear + dispatchPausePointTriggerCommand = originalDispatch + pausePointStatusPoll = originalPoll + }() + + queryPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + return pausePointArmedStatusResponse(id), nil + } + clearPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + t.Fatal("clearPausePointStatus must not be called when the trigger command name was unknown") + return pausePointStatusResponse{}, nil + } + dispatchPausePointTriggerCommand = func( + ctx context.Context, + connection unityipc.Connection, + command string, + commandArgs []string, + startPath string, + stdout io.Writer, + stderr io.Writer, + ) int { + _, _ = stderr.Write([]byte(unknownCommandTriggerStderr)) + return 1 + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + startedAt := time.Now() + code := runWaitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ + id: "jump", + timeoutSeconds: 60, + timeout: 10 * time.Second, + triggerCommand: "simulate-keybord", + triggerArgs: []string{"--action", "Press", "--key", "Space"}, + }, &stdout, &stderr) + elapsed := time.Since(startedAt) + + if code != 1 { + t.Fatalf("expected failure exit code, got %d with stdout %s", code, stdout.String()) + } + if elapsed >= 5*time.Second { + t.Fatalf("expected an early abort, waited %v of a 10s timeout", elapsed) + } + envelope := parsePausePointErrorEnvelope(t, stderr.Bytes()) + if envelope.Error.ErrorCode != clierrors.ErrorCodePausePointTriggerFailed { + t.Fatalf("error code mismatch: %#v", envelope.Error) + } + if envelope.Error.Retryable || envelope.Error.SafeToRetry { + t.Fatalf("a mistyped command name is not fixed by retrying the same command: %#v", envelope.Error) + } + triggerResult, ok := envelope.Error.Details["TriggerResult"].(map[string]any) + if !ok { + t.Fatalf("TriggerResult detail missing or wrong shape: %#v", envelope.Error.Details) + } + if errorText, _ := triggerResult["Error"].(string); !strings.Contains(errorText, "UNKNOWN_COMMAND") { + t.Fatalf("TriggerResult must carry the trigger's own rejection: %#v", triggerResult) + } +} + +// Verifies a trigger that completes normally mid-wait never aborts the wait and is still reported +// once, so receiving it inside the poll loop does not regress the existing join behavior. +func TestWaitForPausePointDoesNotAbortWhenTriggerSucceeds(t *testing.T) { + originalQuery := queryPausePointStatus + originalDispatch := dispatchPausePointTriggerCommand + originalPoll := pausePointStatusPoll + pausePointStatusPoll = time.Millisecond + defer func() { + queryPausePointStatus = originalQuery + dispatchPausePointTriggerCommand = originalDispatch + pausePointStatusPoll = originalPoll + }() + + queryPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + return pausePointArmedStatusResponse(id), nil + } + dispatchPausePointTriggerCommand = func( + ctx context.Context, + connection unityipc.Connection, + command string, + commandArgs []string, + startPath string, + stdout io.Writer, + stderr io.Writer, + ) int { + _, _ = stdout.Write([]byte(`{"Success":true}`)) + return 0 + } + + _, state, triggerResult, _, err := waitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ + id: "jump", + timeoutSeconds: 60, + timeout: 50 * time.Millisecond, + triggerCommand: "simulate-keyboard", + triggerArgs: []string{"--action", "Press"}, + }) + if err != nil { + t.Fatalf("waitForPausePoint failed: %v", err) + } + if state != pausePointWaitStateTimeout { + t.Fatalf("a successful trigger must leave the wait to settle on its own, got state %q", state) + } + if triggerResult == nil || !triggerResult.Completed { + t.Fatalf("expected a completed trigger result, got %#v", triggerResult) + } + if triggerResult.Error != "" { + t.Fatalf("expected no trigger error, got %#v", triggerResult) + } +} + +// Verifies an already-hit marker observed by the status query taken just before aborting is +// reported as a hit, so a trigger rejection racing a real hit does not discard the hit. +func TestWaitForPausePointReportsHitRacingATriggerRejection(t *testing.T) { + originalQuery := queryPausePointStatus + originalDispatch := dispatchPausePointTriggerCommand + originalPoll := pausePointStatusPoll + pausePointStatusPoll = time.Hour + defer func() { + queryPausePointStatus = originalQuery + dispatchPausePointTriggerCommand = originalDispatch + pausePointStatusPoll = originalPoll + }() + + // Only the abort-time query can observe the hit: the poll ticker is parked for an hour, so the + // loop's own next status query never runs within this test. + queryCount := 0 + queryPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + queryCount++ + if queryCount <= 2 { + return pausePointArmedStatusResponse(id), nil + } + return pausePointStatusResponse{ + Id: id, + Status: pausePointStatusHit, + IsHit: true, + HitCount: 1, + EditorState: pausePointEditorState{IsPlaying: true, IsPaused: true, CapturedAt: "PausePointHit"}, + }, nil + } + dispatchPausePointTriggerCommand = func( + ctx context.Context, + connection unityipc.Connection, + command string, + commandArgs []string, + startPath string, + stdout io.Writer, + stderr io.Writer, + ) int { + _, _ = stderr.Write([]byte(argumentErrorTriggerStderr)) + return 1 + } + + response, state, triggerResult, _, err := waitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ + id: "jump", + timeoutSeconds: 60, + timeout: 10 * time.Second, + triggerCommand: "simulate-keyboard", + triggerArgs: []string{"--action", "Hold"}, + }) + if err != nil { + t.Fatalf("waitForPausePoint failed: %v", err) + } + if state != pausePointWaitStateHit { + t.Fatalf("expected the raced hit to win, got state %q", state) + } + if !response.IsHit { + t.Fatalf("expected the hit response to be returned, got %#v", response) + } + if triggerResult == nil || triggerResult.Error == "" { + t.Fatalf("expected the rejection to still be reported alongside the hit, got %#v", triggerResult) + } +} + +// Verifies aborting a wait that resumed PlayMode itself puts PlayMode back into pause and reports +// that on ResumePlayResult, so gameplay cannot consume the preserved marker's single shot while the +// --trigger value is being fixed. +func TestRunWaitForPausePointRepausesPlayModeWhenTriggerRejectsArguments(t *testing.T) { + originalQuery := queryPausePointStatus + originalClear := clearPausePointStatus + originalDispatch := dispatchPausePointTriggerCommand + originalResume := resumePlayModeForPausePoint + originalSend := sendControlPlayModeForPausePoint + originalPoll := pausePointStatusPoll + pausePointStatusPoll = time.Millisecond + defer func() { + queryPausePointStatus = originalQuery + clearPausePointStatus = originalClear + dispatchPausePointTriggerCommand = originalDispatch + resumePlayModeForPausePoint = originalResume + sendControlPlayModeForPausePoint = originalSend + pausePointStatusPoll = originalPoll + }() + + queryPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + return pausePointArmedStatusResponse(id), nil + } + clearPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + t.Fatal("clearPausePointStatus must not be called when the trigger was rejected") + return pausePointStatusResponse{}, nil + } + resumePlayModeForPausePoint = func( + ctx context.Context, + connection unityipc.Connection, + ) pausePointResumePlayResult { + return pausePointResumePlayResult{WasPaused: true, Resumed: true} + } + actions := make([]string, 0, 1) + sendControlPlayModeForPausePoint = func( + ctx context.Context, + connection unityipc.Connection, + action string, + ) (controlPlayModeToolResponse, error) { + actions = append(actions, action) + return controlPlayModeToolResponse{Success: true, IsPaused: true}, nil + } + dispatchPausePointTriggerCommand = func( + ctx context.Context, + connection unityipc.Connection, + command string, + commandArgs []string, + startPath string, + stdout io.Writer, + stderr io.Writer, + ) int { + _, _ = stderr.Write([]byte(argumentErrorTriggerStderr)) + return 1 + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runWaitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ + id: "jump", + timeoutSeconds: 60, + timeout: 10 * time.Second, + triggerCommand: "simulate-keyboard", + triggerArgs: []string{"--action", "Hold"}, + resumePlay: true, + }, &stdout, &stderr) + + if code != 1 { + t.Fatalf("expected failure exit code, got %d with stdout %s", code, stdout.String()) + } + if len(actions) != 1 || actions[0] != "Pause" { + t.Fatalf("expected exactly one Pause request on abort, got %#v", actions) + } + + envelope := parsePausePointErrorEnvelope(t, stderr.Bytes()) + resumeResult, ok := envelope.Error.Details["ResumePlayResult"].(map[string]any) + if !ok { + t.Fatalf("ResumePlayResult detail missing or wrong shape: %#v", envelope.Error.Details) + } + if resumeResult["Resumed"] != true { + t.Fatalf("ResumePlayResult must still report the resume it performed: %#v", resumeResult) + } + if resumeResult["Repaused"] != true { + t.Fatalf("ResumePlayResult must report the re-pause performed on abort: %#v", resumeResult) + } + if _, hasError := resumeResult["RepauseError"]; hasError { + t.Fatalf("expected no RepauseError for a successful re-pause: %#v", resumeResult) + } +} + +// Verifies a failed re-pause is reported rather than silently dropped, since gameplay then keeps +// running and can still consume the preserved marker. +func TestRunWaitForPausePointReportsRepauseFailure(t *testing.T) { + originalQuery := queryPausePointStatus + originalDispatch := dispatchPausePointTriggerCommand + originalResume := resumePlayModeForPausePoint + originalSend := sendControlPlayModeForPausePoint + originalPoll := pausePointStatusPoll + pausePointStatusPoll = time.Millisecond + defer func() { + queryPausePointStatus = originalQuery + dispatchPausePointTriggerCommand = originalDispatch + resumePlayModeForPausePoint = originalResume + sendControlPlayModeForPausePoint = originalSend + pausePointStatusPoll = originalPoll + }() + + queryPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + return pausePointArmedStatusResponse(id), nil + } + resumePlayModeForPausePoint = func( + ctx context.Context, + connection unityipc.Connection, + ) pausePointResumePlayResult { + return pausePointResumePlayResult{WasPaused: true, Resumed: true} + } + sendControlPlayModeForPausePoint = func( + ctx context.Context, + connection unityipc.Connection, + action string, + ) (controlPlayModeToolResponse, error) { + return controlPlayModeToolResponse{Success: false, Message: "pause denied"}, nil + } + dispatchPausePointTriggerCommand = func( + ctx context.Context, + connection unityipc.Connection, + command string, + commandArgs []string, + startPath string, + stdout io.Writer, + stderr io.Writer, + ) int { + _, _ = stderr.Write([]byte(argumentErrorTriggerStderr)) + return 1 + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runWaitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ + id: "jump", + timeoutSeconds: 60, + timeout: 10 * time.Second, + triggerCommand: "simulate-keyboard", + triggerArgs: []string{"--action", "Hold"}, + resumePlay: true, + }, &stdout, &stderr) + + if code != 1 { + t.Fatalf("expected failure exit code, got %d with stdout %s", code, stdout.String()) + } + envelope := parsePausePointErrorEnvelope(t, stderr.Bytes()) + resumeResult, ok := envelope.Error.Details["ResumePlayResult"].(map[string]any) + if !ok { + t.Fatalf("ResumePlayResult detail missing or wrong shape: %#v", envelope.Error.Details) + } + if resumeResult["Repaused"] == true { + t.Fatalf("a denied Pause must not be reported as re-paused: %#v", resumeResult) + } + if repauseError, _ := resumeResult["RepauseError"].(string); !strings.Contains(repauseError, "pause denied") { + t.Fatalf("expected the Pause failure to be reported: %#v", resumeResult) + } +} + +// Verifies --resume-play that found PlayMode already unpaused sends no Pause on abort: this command +// resumed nothing, so pausing a game the caller was running would be an unrequested side effect. +func TestWaitForPausePointDoesNotRepauseWhenResumeWasANoOp(t *testing.T) { + originalQuery := queryPausePointStatus + originalDispatch := dispatchPausePointTriggerCommand + originalResume := resumePlayModeForPausePoint + originalSend := sendControlPlayModeForPausePoint + originalPoll := pausePointStatusPoll + pausePointStatusPoll = time.Millisecond + defer func() { + queryPausePointStatus = originalQuery + dispatchPausePointTriggerCommand = originalDispatch + resumePlayModeForPausePoint = originalResume + sendControlPlayModeForPausePoint = originalSend + pausePointStatusPoll = originalPoll + }() + + queryPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + return pausePointArmedStatusResponse(id), nil + } + resumePlayModeForPausePoint = func( + ctx context.Context, + connection unityipc.Connection, + ) pausePointResumePlayResult { + // Mirrors the real contract: Play is only sent when Status reports IsPaused=true. + return pausePointResumePlayResult{WasPaused: false, Resumed: false} + } + sendControlPlayModeForPausePoint = func( + ctx context.Context, + connection unityipc.Connection, + action string, + ) (controlPlayModeToolResponse, error) { + t.Fatalf("unexpected control-play-mode %q request after a no-op resume", action) + return controlPlayModeToolResponse{}, nil + } + dispatchPausePointTriggerCommand = func( + ctx context.Context, + connection unityipc.Connection, + command string, + commandArgs []string, + startPath string, + stdout io.Writer, + stderr io.Writer, + ) int { + _, _ = stderr.Write([]byte(argumentErrorTriggerStderr)) + return 1 + } + + _, state, _, resumeResult, err := waitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ + id: "jump", + timeoutSeconds: 60, + timeout: 10 * time.Second, + triggerCommand: "simulate-keyboard", + triggerArgs: []string{"--action", "Hold"}, + resumePlay: true, + }) + if err != nil { + t.Fatalf("waitForPausePoint failed: %v", err) + } + if state != pausePointWaitStateTriggerFailed { + t.Fatalf("expected trigger_failed state, got %q", state) + } + if resumeResult == nil || resumeResult.Resumed { + t.Fatalf("expected a no-op ResumePlayResult, got %#v", resumeResult) + } + if resumeResult.Repaused || resumeResult.RepauseError != "" { + t.Fatalf("expected no re-pause to be reported, got %#v", resumeResult) + } +} + +// Verifies a wait that did not resume PlayMode itself never sends Pause on abort: pausing a game +// this command did not resume would be an unrequested side effect. +func TestWaitForPausePointDoesNotRepauseWhenItDidNotResume(t *testing.T) { + originalQuery := queryPausePointStatus + originalDispatch := dispatchPausePointTriggerCommand + originalSend := sendControlPlayModeForPausePoint + originalPoll := pausePointStatusPoll + pausePointStatusPoll = time.Millisecond + defer func() { + queryPausePointStatus = originalQuery + dispatchPausePointTriggerCommand = originalDispatch + sendControlPlayModeForPausePoint = originalSend + pausePointStatusPoll = originalPoll + }() + + queryPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + return pausePointArmedStatusResponse(id), nil + } + sendControlPlayModeForPausePoint = func( + ctx context.Context, + connection unityipc.Connection, + action string, + ) (controlPlayModeToolResponse, error) { + t.Fatalf("unexpected control-play-mode %q request", action) + return controlPlayModeToolResponse{}, nil + } + dispatchPausePointTriggerCommand = func( + ctx context.Context, + connection unityipc.Connection, + command string, + commandArgs []string, + startPath string, + stdout io.Writer, + stderr io.Writer, + ) int { + _, _ = stderr.Write([]byte(argumentErrorTriggerStderr)) + return 1 + } + + _, state, _, resumeResult, err := waitForPausePoint(context.Background(), unityipc.Connection{}, waitForPausePointOptions{ + id: "jump", + timeoutSeconds: 60, + timeout: 10 * time.Second, + triggerCommand: "simulate-keyboard", + triggerArgs: []string{"--action", "Hold"}, + }) + if err != nil { + t.Fatalf("waitForPausePoint failed: %v", err) + } + if state != pausePointWaitStateTriggerFailed { + t.Fatalf("expected trigger_failed state, got %q", state) + } + if resumeResult != nil { + t.Fatalf("expected nil ResumePlayResult when --resume-play was not given, got %#v", resumeResult) + } +} + +// Verifies only a pre-execution rejection (bad arguments or an unknown command name) aborts the +// wait: any other trigger failure (a connection drop, a disabled tool, unparseable output) must +// leave the hit wait running. +func TestPausePointTriggerRejectedBeforeExecution(t *testing.T) { + cases := []struct { + name string + result *pausePointTriggerResult + want bool + }{ + { + name: "no trigger result", + result: nil, + want: false, + }, + { + name: "argument rejection", + result: &pausePointTriggerResult{Completed: true, Error: argumentErrorTriggerStderr}, + want: true, + }, + { + name: "unknown command name rejection", + result: &pausePointTriggerResult{Completed: true, Error: unknownCommandTriggerStderr}, + want: true, + }, + { + name: "another error code does not abort", + result: &pausePointTriggerResult{ + Completed: true, + Error: `{"Success":false,"Error":{"ErrorCode":"UNITY_NOT_REACHABLE","Phase":"connection"}}`, + }, + want: false, + }, + { + name: "unparseable error does not abort", + result: &pausePointTriggerResult{Completed: true, Error: "unknown command: bogus-command"}, + want: false, + }, + { + name: "successful trigger response does not abort", + result: &pausePointTriggerResult{Completed: true, Response: []byte(`{"Success":true}`)}, + want: false, + }, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + if got := pausePointTriggerRejectedBeforeExecution(testCase.result); got != testCase.want { + t.Fatalf("pausePointTriggerRejectedBeforeExecution mismatch: got %v, want %v", got, testCase.want) + } + }) + } +} diff --git a/cli/project-runner/internal/projectrunner/pause_point_wait_test.go b/cli/project-runner/internal/projectrunner/pause_point_wait_test.go index 55eb131e5a..275927d1e3 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_wait_test.go +++ b/cli/project-runner/internal/projectrunner/pause_point_wait_test.go @@ -1126,27 +1126,27 @@ func TestRunProjectLocalPausePointStatusRespectsToolSettings(t *testing.T) { } } -// Verifies an unrecognized flag on await-pause-point/pause-point-status carries a hint -// that the installed project runner may be older than the skill docs. -func TestParseUnknownOptionErrorsIncludeOutdatedRunnerHint(t *testing.T) { - wantHint := "Unknown option \"--bogus-flag\" for await-pause-point. If the skill documentation mentions this option, the installed project runner may be older than the docs — check 'uloop --version' and update the CLI." +// Verifies a flag that exists nowhere is reported as a plain unknown option: no stale-runner hint, +// since the flag being absent from every command means the docs cannot be ahead of this build. +func TestParseUnknownOptionErrorsOmitStaleRunnerHint(t *testing.T) { + wantMessage := `Unknown option "--bogus-flag" for await-pause-point.` _, err := parseWaitForPausePointOptions([]string{"--id", "jump", "--bogus-flag", "value"}) if err == nil { t.Fatal("expected error for unknown flag") } - if err.Error() != wantHint { - t.Fatalf("await-pause-point hint mismatch: %v", err) + if err.Error() != wantMessage { + t.Fatalf("await-pause-point message mismatch: %v", err) } - wantStatusHint := "Unknown option \"--bogus-flag\" for pause-point-status. If the skill documentation mentions this option, the installed project runner may be older than the docs — check 'uloop --version' and update the CLI." + wantStatusMessage := `Unknown option "--bogus-flag" for pause-point-status.` _, statusErr := parsePausePointStatusOptions([]string{"--id", "jump", "--bogus-flag", "value"}) if statusErr == nil { t.Fatal("expected error for unknown flag") } - if statusErr.Error() != wantStatusHint { - t.Fatalf("pause-point-status hint mismatch: %v", statusErr) + if statusErr.Error() != wantStatusMessage { + t.Fatalf("pause-point-status message mismatch: %v", statusErr) } } diff --git a/cli/project-runner/internal/projectrunner/pause_point_warning_join_test.go b/cli/project-runner/internal/projectrunner/pause_point_warning_join_test.go new file mode 100644 index 0000000000..4f7dbb09f3 --- /dev/null +++ b/cli/project-runner/internal/projectrunner/pause_point_warning_join_test.go @@ -0,0 +1,55 @@ +package projectrunner + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/hatayama/unity-cli-loop/common/unityipc" +) + +func runAwaitWithoutTrigger(t *testing.T) string { + t.Helper() + + 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 the hit to be a success, got %d with stderr %s", code, stderr.String()) + } + return stdout.String() +} + +// Verifies Unity's own warning reaches the caller on a hit. The CLI's Warning field shadows the +// embedded Unity one — both serialize as "Warning" — so Unity's text is dropped unless joined in. +func TestRunWaitForPausePointKeepsUnityWarningOnAHit(t *testing.T) { + stubPausePointHit(t, "Unity-side enable warning.") + stubPausePointMatchingLogs(t, nil) + + result := decodePausePointWaitResult(t, runAwaitWithoutTrigger(t)) + + if !strings.Contains(result.Warning, "Unity-side enable warning.") { + t.Errorf("Unity's warning was dropped: %q", result.Warning) + } +} + +// Verifies Unity's warning also survives the failed-log-fetch branch, which builds a different +// payload shape and previously had no Warning field at all. +func TestRunWaitForPausePointKeepsUnityWarningWhenTheLogFetchFails(t *testing.T) { + stubPausePointHit(t, "Unity-side enable warning.") + stubPausePointMatchingLogs(t, errors.New("unity busy")) + + result := decodePausePointWaitResult(t, runAwaitWithoutTrigger(t)) + + if !strings.Contains(result.Warning, "Unity-side enable warning.") { + t.Errorf("Unity's warning was dropped on the failed-fetch branch: %q", result.Warning) + } +} diff --git a/cli/project-runner/internal/projectrunner/run.go b/cli/project-runner/internal/projectrunner/run.go index 245bd4f786..39b86ede22 100644 --- a/cli/project-runner/internal/projectrunner/run.go +++ b/cli/project-runner/internal/projectrunner/run.go @@ -261,7 +261,7 @@ func runList(ctx context.Context, connection unityipc.Connection, stdout io.Writ }) return 1 } - clicore.WriteJSON(stdout, formatToolListResult(outcome.Result)) + clicore.WriteJSON(stdout, formatToolListResult(outcome.Result, connection.ProjectRoot)) return 0 } diff --git a/cli/project-runner/shared-inputs-stamp.json b/cli/project-runner/shared-inputs-stamp.json index 9894200bf5..086c6efc93 100644 --- a/cli/project-runner/shared-inputs-stamp.json +++ b/cli/project-runner/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "d40ac2627a8c1782cc77db9c76d45054df44a20e" + "sharedInputsHash": "b85b6271eda91daf75fb620f635266c7a709f90b" } diff --git a/cli/release-automation/cmd/sync-tool-docs/main.go b/cli/release-automation/cmd/sync-tool-docs/main.go new file mode 100644 index 0000000000..be60340cc3 --- /dev/null +++ b/cli/release-automation/cmd/sync-tool-docs/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "flag" + "os" + + "github.com/hatayama/unity-cli-loop/tools/release-automation/internal/automation" +) + +func main() { + repositoryRoot := flag.String("repository-root", ".", "repository root holding the Unity package and the tool catalog") + checkOnly := flag.Bool("check", false, "verify the catalog matches the skill parameter tables instead of writing it") + flag.Parse() + + os.Exit(automation.RunSyncToolDocs(os.Stdout, os.Stderr, automation.SyncToolDocsConfig{ + RepositoryRoot: *repositoryRoot, + CheckOnly: *checkOnly, + })) +} diff --git a/cli/release-automation/internal/automation/release_trigger_guard.go b/cli/release-automation/internal/automation/release_trigger_guard.go index 3e60059b79..87dd7e3965 100644 --- a/cli/release-automation/internal/automation/release_trigger_guard.go +++ b/cli/release-automation/internal/automation/release_trigger_guard.go @@ -156,6 +156,7 @@ var sharedCommonPackageRoots = []string{ "cli/common/ipcendpoint/", "cli/common/progress/", "cli/common/project/", + "cli/common/skilldocs/", "cli/common/skillscan/", "cli/common/tooldocs/", "cli/common/tools/", @@ -186,10 +187,15 @@ func isCommonGoSourceUnderPackageRoots(file string, packageRoots []string) bool if !strings.HasPrefix(file, "cli/common/") { return false } - // JSON files under common (contract.json, default-tools.json) are - // release-please stamp targets rather than binary inputs, and test files - // never ship, so only code and embedded runtime scripts count as release inputs. - if strings.HasSuffix(file, "_test.go") || (!strings.HasSuffix(file, ".go") && !strings.HasSuffix(file, ".ps1")) { + // JSON files under common (contract.json) are release-please stamp targets rather than binary + // inputs, and test files never ship, so only code and embedded runtime scripts count as release + // inputs. The embedded tool catalog is the exception: it is compiled into both binaries and is + // generated from the skill parameter tables, so a change to a tool description that shipped no new + // binary would be help text nobody receives. + if strings.HasSuffix(file, "_test.go") { + return false + } + if file != CatalogRelativePath && !strings.HasSuffix(file, ".go") && !strings.HasSuffix(file, ".ps1") { return false } for _, packageRoot := range packageRoots { diff --git a/cli/release-automation/internal/automation/release_trigger_guard_test.go b/cli/release-automation/internal/automation/release_trigger_guard_test.go index 31c8cab153..47514cb169 100644 --- a/cli/release-automation/internal/automation/release_trigger_guard_test.go +++ b/cli/release-automation/internal/automation/release_trigger_guard_test.go @@ -101,7 +101,34 @@ func TestReleaseTriggerGuardIgnoresNonBinaryCommonChanges(t *testing.T) { "cli/common/clicore/output_test.go", "cli/common/clitest/clitest.go", "cli/common/clicontract/contract.json", - "cli/common/tools/default-tools.json", + }) + + if len(result.Violations) != 0 { + t.Fatalf("expected no violations, got %v", result.Violations) + } +} + +// Verifies the embedded tool catalog counts as a shared release input, since it is compiled into both +// binaries and now changes whenever a skill parameter table does - a description-only change that +// shipped no new binary would be help text nobody receives. +func TestReleaseTriggerGuardCoversTheEmbeddedToolCatalog(t *testing.T) { + result := AnalyzeReleaseTriggerGuard([]string{CatalogRelativePath}) + + if len(result.Violations) != 1 { + t.Fatalf("expected one violation, got %v", result.Violations) + } + if len(result.Violations[0].MissingTriggerRoots) != 2 { + t.Fatalf("expected both release triggers to be required, got %v", result.Violations[0].MissingTriggerRoots) + } +} + +// Verifies the catalog passes once both release triggers are stamped, the sequence a skill edit and a +// regeneration go through together. +func TestReleaseTriggerGuardAcceptsTheEmbeddedToolCatalogWithBothTriggers(t *testing.T) { + result := AnalyzeReleaseTriggerGuard([]string{ + CatalogRelativePath, + "cli/dispatcher/shared-inputs-stamp.json", + "cli/project-runner/shared-inputs-stamp.json", }) if len(result.Violations) != 0 { diff --git a/cli/release-automation/internal/automation/tool_docs_json_editor.go b/cli/release-automation/internal/automation/tool_docs_json_editor.go new file mode 100644 index 0000000000..f51b0de07c --- /dev/null +++ b/cli/release-automation/internal/automation/tool_docs_json_editor.go @@ -0,0 +1,223 @@ +package automation + +import ( + "bytes" + "encoding/json" + "fmt" + "sort" + "strings" +) + +// descriptionLocation is where one description string literal sits in the catalog file, quotes +// included, together with the tool and property it belongs to. Property is empty for a tool-level +// description. +type descriptionLocation struct { + Tool string + Property string + Start int + End int +} + +// replaceCatalogDescriptions rewrites only the description string literals the caller asks for and +// returns the whole file otherwise byte-identical. +// +// A decode-edit-encode round trip through tools.ToolDefinition is deliberately not used: the struct +// drops zero-value defaults ("default": 0 / false / ""), adds an empty parameterSchema to every +// tool, and reorders properties from Unity's declaration order into map order. All three would be +// invisible in the generator's own tests and glaring in the catalog, so the edit is textual and the +// bytes around it never move. +func replaceCatalogDescriptions(content []byte, replacements map[descriptionKey]string) ([]byte, error) { + locations, err := collectDescriptionLocations(content) + if err != nil { + return nil, err + } + + // Applied back to front so an earlier edit never shifts a later offset. + sort.Slice(locations, func(first int, second int) bool { + return locations[first].Start > locations[second].Start + }) + + edited := content + for _, location := range locations { + description, ok := replacements[descriptionKey{Tool: location.Tool, Property: location.Property}] + if !ok { + continue + } + encoded, err := encodeJSONString(description) + if err != nil { + return nil, err + } + edited = append(edited[:location.Start:location.Start], append(encoded, edited[location.End:]...)...) + } + return edited, nil +} + +// encodeJSONString encodes one string the way the catalog is written: HTML escaping off, so a "<" in +// a description stays a "<" instead of becoming "<" and rewriting bytes nobody asked to change. +func encodeJSONString(value string) ([]byte, error) { + buffer := bytes.Buffer{} + encoder := json.NewEncoder(&buffer) + encoder.SetEscapeHTML(false) + if err := encoder.Encode(value); err != nil { + return nil, err + } + return []byte(strings.TrimSuffix(buffer.String(), "\n")), nil +} + +// collectDescriptionLocations walks the catalog and records the byte range of every tool-level and +// property-level description literal. +func collectDescriptionLocations(content []byte) ([]descriptionLocation, error) { + decoder := json.NewDecoder(bytes.NewReader(content)) + walker := &catalogWalker{content: content} + if err := walker.walkValue(decoder, nil); err != nil { + return nil, err + } + + locations := make([]descriptionLocation, 0, len(walker.pending)) + for _, location := range walker.pending { + toolName, ok := walker.toolNames[location.Tool] + if !ok { + return nil, fmt.Errorf("tool at index %s has no name field", location.Tool) + } + location.Tool = toolName + locations = append(locations, location) + } + return locations, nil +} + +type catalogWalker struct { + content []byte + // toolNames is filled as "tools" is walked, keyed by array index. A tool's name may appear after + // its description, so pending ranges are resolved to tool names only once the walk is done. + toolNames map[string]string + pending []descriptionLocation +} + +func (walker *catalogWalker) walkValue(decoder *json.Decoder, path []string) error { + token, err := decoder.Token() + if err != nil { + return err + } + + switch typed := token.(type) { + case json.Delim: + switch typed { + case '{': + return walker.walkObject(decoder, path) + case '[': + return walker.walkArray(decoder, path) + } + return fmt.Errorf("unexpected delimiter %q at path %s", typed, strings.Join(path, ".")) + case string: + walker.recordString(decoder, path, typed) + return nil + default: + return nil + } +} + +func (walker *catalogWalker) walkObject(decoder *json.Decoder, path []string) error { + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return err + } + key, ok := keyToken.(string) + if !ok { + return fmt.Errorf("object key was not a string at path %s", strings.Join(path, ".")) + } + if err := walker.walkValue(decoder, append(path, key)); err != nil { + return err + } + } + _, err := decoder.Token() + return err +} + +func (walker *catalogWalker) walkArray(decoder *json.Decoder, path []string) error { + index := 0 + for decoder.More() { + if err := walker.walkValue(decoder, append(path, fmt.Sprint(index))); err != nil { + return err + } + index++ + } + _, err := decoder.Token() + return err +} + +func (walker *catalogWalker) recordString(decoder *json.Decoder, path []string, value string) { + toolIndex, remainder, ok := catalogToolPath(path) + if !ok { + return + } + + if len(remainder) == 1 && remainder[0] == "name" { + if walker.toolNames == nil { + walker.toolNames = map[string]string{} + } + walker.toolNames[toolIndex] = value + return + } + + property, ok := catalogDescriptionProperty(remainder) + if !ok { + return + } + start, end, ok := stringLiteralRange(walker.content, int(decoder.InputOffset())) + if !ok { + return + } + walker.pending = append(walker.pending, descriptionLocation{ + Tool: toolIndex, + Property: property, + Start: start, + End: end, + }) +} + +// catalogToolPath splits a path such as ["tools","3","inputSchema",...] into the tool index and the +// remainder below it. +func catalogToolPath(path []string) (string, []string, bool) { + if len(path) < 2 || path[0] != "tools" { + return "", nil, false + } + return path[1], path[2:], true +} + +// catalogDescriptionProperty reports which description a path below a tool points at: the tool's own +// ("") or one property's (the property name). +func catalogDescriptionProperty(remainder []string) (string, bool) { + if len(remainder) == 1 && remainder[0] == "description" { + return "", true + } + if len(remainder) != 4 || remainder[1] != "properties" || remainder[3] != "description" { + return "", false + } + if remainder[0] != "inputSchema" && remainder[0] != "parameterSchema" { + return "", false + } + return remainder[2], true +} + +// stringLiteralRange finds the string literal, quotes included, that ends at endOffset. Scanning +// backwards for the opening quote keeps the range exact even for a value carrying escapes, which +// re-encoding the decoded value could not guarantee. +func stringLiteralRange(content []byte, endOffset int) (int, int, bool) { + if endOffset <= 0 || endOffset > len(content) || content[endOffset-1] != '"' { + return 0, 0, false + } + for index := endOffset - 2; index >= 0; index-- { + if content[index] != '"' { + continue + } + backslashes := 0 + for probe := index - 1; probe >= 0 && content[probe] == '\\'; probe-- { + backslashes++ + } + if backslashes%2 == 0 { + return index, endOffset, true + } + } + return 0, 0, false +} diff --git a/cli/release-automation/internal/automation/tool_docs_sync.go b/cli/release-automation/internal/automation/tool_docs_sync.go new file mode 100644 index 0000000000..d822ec264f --- /dev/null +++ b/cli/release-automation/internal/automation/tool_docs_sync.go @@ -0,0 +1,205 @@ +// Package automation hosts the logic behind the release and CI commands. This file generates the +// description text in the embedded tool catalog from the package's own SKILL.md parameter tables, so +// the catalog is a build artifact of the skills rather than a third place to hand-maintain help text. +package automation + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/hatayama/unity-cli-loop/common/skilldocs" + "github.com/hatayama/unity-cli-loop/common/tooldocs" + "github.com/hatayama/unity-cli-loop/common/tools" +) + +// CatalogRelativePath is the generated file, relative to the repository root. It is the only artifact +// this generator writes. +const CatalogRelativePath = "cli/common/tools/default-tools.json" + +// SyncToolDocsConfig selects the repository to work on and whether to verify instead of write. +type SyncToolDocsConfig struct { + RepositoryRoot string + CheckOnly bool +} + +// toolsWithoutParameterTable are the tools allowed to have no parameter table. focus-window takes no +// parameters at all, so a table would have no rows to hold; its tool description still comes from its +// skill. The count is asserted against the catalog so a new tool cannot silently join this list. +// +// This is deliberately not DefaultToolsCatalogDriftTests' CliOwnedCommandsWithoutLiveUnityTools: that +// list names commands with no live Unity tool, which is a different question from having no +// parameters. +var toolsWithoutParameterTable = map[string]bool{ + "focus-window": true, +} + +// descriptionKey identifies one description in the catalog. Property is empty for the tool's own +// description. +type descriptionKey struct { + Tool string + Property string +} + +// RunSyncToolDocs regenerates the catalog's description text, or in check mode reports that it is out +// of date. Any mismatch between a schema and its skill table is an error rather than a silent skip: +// one of the two is stale, and only a human can say which. +func RunSyncToolDocs(stdout io.Writer, stderr io.Writer, config SyncToolDocsConfig) int { + catalogPath := filepath.Join(config.RepositoryRoot, filepath.FromSlash(CatalogRelativePath)) + content, err := os.ReadFile(catalogPath) + if err != nil { + _, _ = fmt.Fprintf(stderr, "failed to read %s: %v\n", CatalogRelativePath, err) + return 1 + } + + generated, err := GenerateCatalogWithSkillDescriptions(content, config.RepositoryRoot) + if err != nil { + _, _ = fmt.Fprintf(stderr, "%v\n", err) + return 1 + } + + if config.CheckOnly { + if string(generated) == string(content) { + _, _ = fmt.Fprintf(stdout, "%s matches the skill parameter tables.\n", CatalogRelativePath) + return 0 + } + _, _ = fmt.Fprintf(stderr, + "%s no longer matches the skill parameter tables. Run scripts/sync-tool-docs.sh and commit the result.\n", + CatalogRelativePath) + return 1 + } + + if string(generated) == string(content) { + _, _ = fmt.Fprintf(stdout, "%s is already up to date.\n", CatalogRelativePath) + return 0 + } + if err := os.WriteFile(catalogPath, generated, 0o644); err != nil { + _, _ = fmt.Fprintf(stderr, "failed to write %s: %v\n", CatalogRelativePath, err) + return 1 + } + _, _ = fmt.Fprintf(stdout, "Updated %s from the skill parameter tables.\n", CatalogRelativePath) + return 0 +} + +// GenerateCatalogWithSkillDescriptions returns the catalog content with every description replaced by +// the text its skill states, leaving all other bytes untouched. +func GenerateCatalogWithSkillDescriptions(content []byte, repositoryRoot string) ([]byte, error) { + catalog := tools.ToolCatalog{} + if err := json.Unmarshal(content, &catalog); err != nil { + return nil, fmt.Errorf("failed to parse %s: %w", CatalogRelativePath, err) + } + + documented := skilldocs.Load(repositoryRoot) + if len(documented) == 0 { + return nil, fmt.Errorf("no skills were found under %s; is this the repository root?", repositoryRoot) + } + if err := verifyTablelessToolsCoverTheCatalog(catalog); err != nil { + return nil, err + } + + replacements := map[descriptionKey]string{} + // Every mismatch is reported, not just the first: a table that fell behind the schema usually did + // so for several tools at once, and fixing them one round trip at a time is what made the drift + // accumulate in the first place. + problems := []string{} + for _, tool := range catalog.Tools { + problems = append(problems, collectToolReplacements(tool, documented, replacements)...) + } + if len(problems) > 0 { + return nil, fmt.Errorf("the skill parameter tables and the tool schemas disagree:\n %s", + strings.Join(problems, "\n ")) + } + return replaceCatalogDescriptions(content, replacements) +} + +// verifyTablelessToolsCoverTheCatalog fails when the catalog grew a tool the allow-list does not +// account for. Without this the count silently drifts and a new tool's missing table looks +// intentional. +func verifyTablelessToolsCoverTheCatalog(catalog tools.ToolCatalog) error { + for toolName := range toolsWithoutParameterTable { + if _, ok := tools.Find(catalog, toolName); !ok { + return fmt.Errorf("%q is allowed to have no parameter table but is not in the catalog", toolName) + } + } + + documentedWithTable := len(catalog.Tools) - len(toolsWithoutParameterTable) + if documentedWithTable <= 0 { + return fmt.Errorf("the catalog holds %d tools, which cannot all be table-less", len(catalog.Tools)) + } + return nil +} + +// collectToolReplacements records one tool's replacements and returns the mismatches found, so the +// caller can report every tool's drift in one run. +func collectToolReplacements( + tool tools.ToolDefinition, + documented map[string]skilldocs.ToolDocs, + replacements map[descriptionKey]string, +) []string { + docs, ok := documented[tool.Name] + if !ok { + return []string{fmt.Sprintf("%s has no skill; every tool's help text must come from a skill", tool.Name)} + } + if docs.ToolDescription == "" { + return []string{fmt.Sprintf("%s has a skill with no tool description", tool.Name)} + } + replacements[descriptionKey{Tool: tool.Name}] = docs.ToolDescription + + problems := []string{} + documentedOptions := map[string]bool{} + for _, propertyName := range sortedPropertyNames(tool) { + property := tool.EffectiveInputSchema().Properties[propertyName] + optionName := tooldocs.OptionNameForProperty(tool.Name, propertyName, property) + if property.Hidden { + // A hidden property never reaches help, so requiring a documented row would force the + // skill to describe something no caller can pass. + continue + } + documentedOptions[optionName] = true + + description, ok := docs.ParamDescriptions[optionName] + if !ok { + problems = append(problems, fmt.Sprintf( + "%s --%s is accepted by the tool but has no row in its skill parameter table", + tool.Name, optionName)) + continue + } + replacements[descriptionKey{Tool: tool.Name, Property: propertyName}] = description + } + + if len(documentedOptions) == 0 && !toolsWithoutParameterTable[tool.Name] { + problems = append(problems, fmt.Sprintf( + "%s accepts no visible parameters but is not listed as table-less", tool.Name)) + } + return append(problems, unknownTableRowProblems(tool.Name, docs, documentedOptions)...) +} + +// unknownTableRowProblems reports table rows matching no accepted option, which means the skill +// documents a flag the implementation dropped or renamed. +func unknownTableRowProblems(toolName string, docs skilldocs.ToolDocs, documentedOptions map[string]bool) []string { + unknown := []string{} + for optionName := range docs.ParamDescriptions { + if !documentedOptions[optionName] { + unknown = append(unknown, "--"+optionName) + } + } + if len(unknown) == 0 { + return nil + } + sort.Strings(unknown) + return []string{fmt.Sprintf("%s documents %s in its skill parameter table, but the tool does not accept them", + toolName, strings.Join(unknown, ", "))} +} + +func sortedPropertyNames(tool tools.ToolDefinition) []string { + names := make([]string, 0, len(tool.EffectiveInputSchema().Properties)) + for propertyName := range tool.EffectiveInputSchema().Properties { + names = append(names, propertyName) + } + sort.Strings(names) + return names +} diff --git a/cli/release-automation/internal/automation/tool_docs_sync_test.go b/cli/release-automation/internal/automation/tool_docs_sync_test.go new file mode 100644 index 0000000000..c05940eacc --- /dev/null +++ b/cli/release-automation/internal/automation/tool_docs_sync_test.go @@ -0,0 +1,286 @@ +package automation + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// fixtureCatalogContent is a miniature default-tools.json with the traits the real file has that a +// struct round trip would destroy: a zero-value default, a hidden property, an enum, and properties +// in declaration rather than alphabetical order. +const fixtureCatalogContent = `{ + "tools": [ + { + "name": "simulate-keyboard", + "description": "Stale tool description", + "inputSchema": { + "type": "object", + "properties": { + "Key": { + "type": "string", + "description": "Stale key description" + }, + "Action": { + "type": "string", + "description": "Stale action description", + "enum": [ + "Press", + "ReleaseAll" + ], + "default": "Press" + }, + "Duration": { + "type": "number", + "description": "Stale duration description", + "default": 0 + }, + "InternalOnly": { + "type": "boolean", + "description": "Not documented anywhere", + "hidden": true + } + } + } + }, + { + "name": "focus-window", + "description": "Stale focus description", + "inputSchema": { + "type": "object", + "properties": {} + } + } + ] +} +` + +const fixtureKeyboardSkill = `--- +name: uloop-simulate-keyboard +toolName: simulate-keyboard +description: "Simulate keyboard input in PlayMode." +--- + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| ` + "`--action`" + ` | enum | ` + "`Press`" + ` | Press \| ReleaseAll | +| ` + "`--key`" + ` | string | - | Key name matching the Input System Key enum | +| ` + "`--duration`" + ` | number | ` + "`0`" + ` | Hold duration in seconds | +` + +const fixtureFocusWindowSkill = `--- +name: uloop-focus-window +description: "Bring the Unity Editor window to front." +--- + +# uloop focus-window +` + +// writeGeneratorFixture builds a repository holding the uloop package and the catalog file, and +// returns its root. +func writeGeneratorFixture(t *testing.T, skills map[string]string, catalogContent string) string { + t.Helper() + + repositoryRoot := t.TempDir() + packageRoot := filepath.Join(repositoryRoot, "Packages", "src") + if err := os.MkdirAll(filepath.Join(packageRoot, "Editor", "FirstPartyTools"), 0o755); err != nil { + t.Fatalf("failed to create the package root: %v", err) + } + writeFixtureFile(t, filepath.Join(packageRoot, "package.json"), `{"name":"io.github.hatayama.uloopmcp"}`) + writeFixtureFile(t, filepath.Join(repositoryRoot, filepath.FromSlash(CatalogRelativePath)), catalogContent) + + for relativeDirectory, content := range skills { + skillPath := filepath.Join(packageRoot, "Editor", relativeDirectory, "Skill", "SKILL.md") + writeFixtureFile(t, skillPath, content) + } + return repositoryRoot +} + +func writeFixtureFile(t *testing.T, path string, content string) { + t.Helper() + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("failed to create %s: %v", filepath.Dir(path), err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write %s: %v", path, err) + } +} + +func defaultGeneratorSkills() map[string]string { + return map[string]string{ + "FirstPartyTools/SimulateKeyboard": fixtureKeyboardSkill, + "CliOnlyTools~/FocusWindow": fixtureFocusWindowSkill, + } +} + +// Verifies generation replaces every description with its skill text and changes nothing else: a +// zero-value default, the property order, the enum and the hidden property all survive byte for byte, +// which a decode-edit-encode round trip would not manage. +func TestGenerateCatalogReplacesOnlyDescriptions(t *testing.T) { + repositoryRoot := writeGeneratorFixture(t, defaultGeneratorSkills(), fixtureCatalogContent) + + generated, err := GenerateCatalogWithSkillDescriptions([]byte(fixtureCatalogContent), repositoryRoot) + if err != nil { + t.Fatalf("generation failed: %v", err) + } + + expected := strings.NewReplacer( + `"Stale tool description"`, `"Simulate keyboard input in PlayMode."`, + `"Stale key description"`, `"Key name matching the Input System Key enum"`, + `"Stale action description"`, `"Press | ReleaseAll"`, + `"Stale duration description"`, `"Hold duration in seconds"`, + `"Stale focus description"`, `"Bring the Unity Editor window to front."`, + ).Replace(fixtureCatalogContent) + if string(generated) != expected { + t.Errorf("generated content differs from the expected byte-for-byte result:\n%s", string(generated)) + } +} + +// Verifies a second run over its own output changes nothing, so the committed file is a fixed point +// and CI's --check cannot fail on a freshly generated catalog. +func TestGenerateCatalogIsIdempotent(t *testing.T) { + repositoryRoot := writeGeneratorFixture(t, defaultGeneratorSkills(), fixtureCatalogContent) + + first, err := GenerateCatalogWithSkillDescriptions([]byte(fixtureCatalogContent), repositoryRoot) + if err != nil { + t.Fatalf("first generation failed: %v", err) + } + second, err := GenerateCatalogWithSkillDescriptions(first, repositoryRoot) + if err != nil { + t.Fatalf("second generation failed: %v", err) + } + + if string(first) != string(second) { + t.Errorf("generation is not idempotent:\n%s", string(second)) + } +} + +// Verifies a property the tool accepts but the skill table omits stops generation, since one of the +// two is stale and writing the catalog anyway would hide which. +func TestGenerateCatalogFailsOnAnUndocumentedProperty(t *testing.T) { + skills := defaultGeneratorSkills() + skills["FirstPartyTools/SimulateKeyboard"] = strings.ReplaceAll( + fixtureKeyboardSkill, "| `--duration` | number | `0` | Hold duration in seconds |\n", "") + repositoryRoot := writeGeneratorFixture(t, skills, fixtureCatalogContent) + + _, err := GenerateCatalogWithSkillDescriptions([]byte(fixtureCatalogContent), repositoryRoot) + + if err == nil { + t.Fatal("an undocumented property must stop generation") + } + if !strings.Contains(err.Error(), "simulate-keyboard --duration") { + t.Errorf("the error must name the undocumented option: %v", err) + } +} + +// Verifies a table row matching no accepted option stops generation, which is the drift left behind +// when an option is renamed or removed. +func TestGenerateCatalogFailsOnAnUnknownTableRow(t *testing.T) { + skills := defaultGeneratorSkills() + skills["FirstPartyTools/SimulateKeyboard"] = fixtureKeyboardSkill + + "| `--removed-flag` | flag | - | No longer accepted |\n" + repositoryRoot := writeGeneratorFixture(t, skills, fixtureCatalogContent) + + _, err := GenerateCatalogWithSkillDescriptions([]byte(fixtureCatalogContent), repositoryRoot) + + if err == nil { + t.Fatal("a table row for an option the tool does not accept must stop generation") + } + if !strings.Contains(err.Error(), "--removed-flag") { + t.Errorf("the error must name the unknown row: %v", err) + } +} + +// Verifies a hidden property needs no table row and keeps its description, because it never reaches +// help and documenting it would describe something no caller can pass. +func TestGenerateCatalogIgnoresHiddenProperties(t *testing.T) { + repositoryRoot := writeGeneratorFixture(t, defaultGeneratorSkills(), fixtureCatalogContent) + + generated, err := GenerateCatalogWithSkillDescriptions([]byte(fixtureCatalogContent), repositoryRoot) + if err != nil { + t.Fatalf("generation failed: %v", err) + } + + if !strings.Contains(string(generated), `"description": "Not documented anywhere"`) { + t.Errorf("a hidden property's description must be left alone:\n%s", string(generated)) + } +} + +// Verifies every mismatch is reported in one run rather than one per invocation, since a table that +// fell behind usually did so for several options at once. +func TestGenerateCatalogReportsEveryMismatchAtOnce(t *testing.T) { + skills := defaultGeneratorSkills() + skills["FirstPartyTools/SimulateKeyboard"] = strings.NewReplacer( + "| `--duration` | number | `0` | Hold duration in seconds |\n", "", + "| `--key` | string | - | Key name matching the Input System Key enum |\n", "", + ).Replace(fixtureKeyboardSkill) + repositoryRoot := writeGeneratorFixture(t, skills, fixtureCatalogContent) + + _, err := GenerateCatalogWithSkillDescriptions([]byte(fixtureCatalogContent), repositoryRoot) + + if err == nil { + t.Fatal("two undocumented properties must stop generation") + } + for _, expected := range []string{"--duration", "--key"} { + if !strings.Contains(err.Error(), expected) { + t.Errorf("the error must name %s: %v", expected, err) + } + } +} + +// Verifies a tool with no skill at all stops generation, so no tool's help text can quietly stay +// hand-maintained in the catalog. +func TestGenerateCatalogFailsWhenAToolHasNoSkill(t *testing.T) { + skills := map[string]string{"CliOnlyTools~/FocusWindow": fixtureFocusWindowSkill} + repositoryRoot := writeGeneratorFixture(t, skills, fixtureCatalogContent) + + _, err := GenerateCatalogWithSkillDescriptions([]byte(fixtureCatalogContent), repositoryRoot) + + if err == nil { + t.Fatal("a tool with no skill must stop generation") + } + if !strings.Contains(err.Error(), "simulate-keyboard has no skill") { + t.Errorf("the error must name the undocumented tool: %v", err) + } +} + +// Verifies check mode reports the committed catalog as stale without writing it, which is what CI +// needs: a red step and an untouched working tree. +func TestRunSyncToolDocsCheckModeReportsAStaleCatalog(t *testing.T) { + repositoryRoot := writeGeneratorFixture(t, defaultGeneratorSkills(), fixtureCatalogContent) + stdout := strings.Builder{} + stderr := strings.Builder{} + + code := RunSyncToolDocs(&stdout, &stderr, SyncToolDocsConfig{RepositoryRoot: repositoryRoot, CheckOnly: true}) + + if code == 0 { + t.Fatalf("a stale catalog must fail check mode: %s", stdout.String()) + } + if !strings.Contains(stderr.String(), "scripts/sync-tool-docs.sh") { + t.Errorf("check mode must name the command that fixes it: %s", stderr.String()) + } + content, err := os.ReadFile(filepath.Join(repositoryRoot, filepath.FromSlash(CatalogRelativePath))) + if err != nil { + t.Fatalf("failed to read the catalog back: %v", err) + } + if string(content) != fixtureCatalogContent { + t.Error("check mode must not write the catalog") + } +} + +// Verifies writing then checking leaves check mode green, the sequence a developer runs before +// committing. +func TestRunSyncToolDocsWritesThenPassesCheck(t *testing.T) { + repositoryRoot := writeGeneratorFixture(t, defaultGeneratorSkills(), fixtureCatalogContent) + stdout := strings.Builder{} + stderr := strings.Builder{} + + if code := RunSyncToolDocs(&stdout, &stderr, SyncToolDocsConfig{RepositoryRoot: repositoryRoot}); code != 0 { + t.Fatalf("write mode failed: %s", stderr.String()) + } + if code := RunSyncToolDocs(&stdout, &stderr, SyncToolDocsConfig{RepositoryRoot: repositoryRoot, CheckOnly: true}); code != 0 { + t.Fatalf("check mode failed right after writing: %s", stderr.String()) + } +} diff --git a/docs/claude-code-sandbox.md b/docs/claude-code-sandbox.md new file mode 100644 index 0000000000..7837992e17 --- /dev/null +++ b/docs/claude-code-sandbox.md @@ -0,0 +1,145 @@ +# Claude Code Sandbox and Unity IPC + +Read this when a `uloop` command fails against a running, healthy Unity Editor while an AI agent +(Claude Code or similar) is executing it through a sandboxed shell. The symptom looks like an IPC +bug and has already cost one full investigation (2026-07-26) that ended in "the Editor was fine +all along" — this document exists so nobody walks that path again. + +## Symptom + +- Any `uloop` command that talks to the Unity Editor (`compile`, `run-tests`, `get-logs`, + `simulate-*`, ...) fails, while Unity itself is demonstrably healthy and the server side never + sees the connection attempt (server-side logs are VibeLogger-based and exist only when the + `ULOOP_DEBUG` scripting define is set — do not read missing log lines as evidence either way). +- Commands that never touch the Editor (`uloop --version`, `uloop --help`) work normally. +- The reported error names the refusal: the command fails on the first attempt with + `ErrorCode: UNITY_NOT_REACHABLE`, `Retryable: false`, `SafeToRetry: false`, and + `Details.Cause` carrying the syscall error verbatim + (`dial unix ...: connect: operation not permitted`). Its next actions point at sandboxing and + socket permissions, not at waiting. An older CLI instead retried for 60 seconds and then + reported `dial unix ...: i/o timeout` with retry guidance — that misdiagnosis is what cost the + 2026-07-26 investigation, so an `i/o timeout` here means the CLI predates the fix. + +## Cause + +Claude Code runs shell commands inside a sandbox whose network policy is expressed as a list of +allowed **hostnames**. A Unix domain socket has no hostname, so there is no way to allowlist the +project socket (`/tmp/uloop-/UnityCliLoop-.sock`, where `` is the first 16 hex +digits of the SHA-256 of the canonical project root) through that policy — `connect()` and +`bind()` on Unix sockets are denied with EPERM regardless of filesystem permissions. Write +access to the socket's directory does not help; this was verified empirically: a directory the +sandbox allowed file writes into still refused a socket `bind()`. + +The block is not specific to the transport: with the default `allowedHosts` policy the sandbox +also stops V2's localhost TCP connection (verified 2026-07-27), so a plain `uloop ...` command +succeeding proves only that `excludedCommands` took it out of the sandbox — not that Unix sockets +are the problem and TCP would get through. What is specific to a Unix socket is only that it has +no hostname to put on `allowedHosts`, so that escape hatch does not exist for it. + +This is specific to the sandboxed shell. The same command in a normal terminal, or in a session +without sandboxing, is unaffected. Windows uses a named pipe instead of a Unix socket; whether +the sandbox blocks named-pipe connects the same way has not been verified — treat an +EPERM-shaped failure there with the same suspicion before blaming the Editor. + +## Why this repository gets hit harder than game projects + +Claude Code's sandbox supports an `excludedCommands` list (personal `settings.json`), and a +typical entry is `"uloop *"`. That pattern matches the **command text**, with these verified +consequences (2026-07-26, all measured in a live sandboxed session): + +| Invocation | Matches `uloop *` | Result | +|---|---|---| +| `uloop get-logs ...` (dispatcher from PATH) | yes — runs outside the sandbox | works | +| `SOME_VAR=... uloop ...` (env-var prefix) | yes (verified empirically) | works | +| `ULOOP_PROJECT_RUNNER_PATH= uloop ...` (or `export` first, then plain `uloop ...`) | yes — the command text still starts with `uloop` | works | +| `dist/darwin-arm64/uloop compile ...` | **no** (verified with the plain literal path) | EPERM | +| raw `socket.connect()` from a script | no | EPERM | + +The exclusion is decided on the **typed command text**, not on which binary ultimately does the +work: the `ULOOP_PROJECT_RUNNER_PATH` row runs a locally built dev runner yet stays excluded, +while the `dist/...` row runs the same kind of dispatcher binary yet gets sandboxed. Game +projects invoke plain `uloop ...` (optionally with the env override) and never notice the +sandbox. This repository's development rule (see `CLAUDE.md` — always validate with the built +`dist//uloop` binary) produces exactly the command shape the exclusion does +**not** match. + +Note the corollary: a successful `uloop ...` command in a sandboxed session does not mean the +sandbox permits Unity IPC — it means the command was excluded from sandboxing entirely. + +## Remedies + +Pick one: + +1. Run dev-binary commands with the sandbox disabled for that command (Claude Code: + `dangerouslyDisableSandbox`; users can manage restrictions via `/sandbox`). +2. Add the dev-binary shapes to `excludedCommands` in the personal Claude Code settings: + `"dist/*/uloop *"` alongside the existing `"uloop *"`, an anchored absolute-path entry such as + `"/Users//ghq//*/dist/*/uloop *"` if you ever type the binary's full path, and the + two `ULOOP_PROJECT_RUNNER_PATH=*` entries from the next section if you pass the override from + a shell variable. All of these are measured, not suggestions (2026-07-27); the next section + says which command shapes each one covers. +3. When the change under review lives in the project runner, keep the sandbox on and run + `ULOOP_PROJECT_RUNNER_PATH= uloop ...` — the plain-`uloop` + command text stays excluded while the dev runner does the work (the override is documented in + `docs/project-runner-pin.md`). This does not exercise dispatcher-side changes; for those, use + remedy 1 or 2. Taking the path from a shell variable in the same command as `uloop` needs an + `excludedCommands` entry that names the variable (see the next section); without one, write + the value literally or `export` it as a separate command first. + +Do not burn time re-investigating the Editor side when the error is EPERM: the Editor never +saw the connection attempt. + +## Which command shapes the exclusion actually covers + +Measured 2026-07-27 in a live sandboxed session. Each row was decided by whether the command +reached Unity or failed at `connect()`. The entries in play were `"uloop *"`, `"dist/*/uloop *"`, +an anchored absolute-path entry (`"/Users//ghq//*/dist/*/uloop *"`), and the two +variable-named entries shown below. + +| Command as typed | Excluded | +|---|---| +| `dist/darwin-arm64/uloop get-logs ...` | yes | +| `/Users//.../dist/darwin-arm64/uloop get-logs ...` (absolute) | yes, via the anchored entry | +| `echo hello; /Users//.../dist/darwin-arm64/uloop get-logs ...` (compound) | yes | +| `SOME_VAR=abc dist/darwin-arm64/uloop get-logs ...` (literal env value) | yes | +| `dist/darwin-arm64/uloop get-logs --project-path "$(git rev-parse --show-toplevel)"` | yes | +| `P=/path; dist/darwin-arm64/uloop get-logs --project-path "$P"` (variable in an argument) | yes | +| `mkdir -p somewhere; dist/darwin-arm64/uloop get-logs ...` (compound) | yes | +| `echo start; uloop get-logs ...` (compound) | yes | +| `P=/path; uloop get-logs --project-path "$P"` | yes | +| `V=abc; export SOME_VAR="$V"` first, then `dist/darwin-arm64/uloop get-logs ...` | yes | +| `R=; ULOOP_PROJECT_RUNNER_PATH="$R" uloop get-logs ...` | yes, via the variable-named entry | +| `R=; ULOOP_PROJECT_RUNNER_PATH="$R" dist/darwin-arm64/uloop get-logs ...` | yes, via the variable-named entry | +| **`V=abc; SOME_VAR="$V" uloop get-logs ...`** (no entry names `SOME_VAR`) | **no — denied** | + +Command substitution in an argument, a variable in an argument, and being part of a compound +command are all harmless, and matching happens per sub-command — `echo hello;` in front of an +absolute-path invocation does not stop it being excluded. Two shapes need an entry of their own: + +- **An absolute path.** The glob is matched against the typed text, so `dist/*/uloop *` only + matches text beginning with `dist/`. An entry whose leading segment is literal — + `"/Users//ghq//*/dist/*/uloop *"` — covers it. Anchoring costs nothing in practice + because matching is per sub-command. A leading wildcard (`"*/dist/*/uloop *"`) would also cover + checkouts outside that root, but how it behaves was not measured, so prefer adding another + anchored entry when a new root appears. +- **A shell variable expanded into an env-var prefix.** `SOME_VAR="$V" uloop ...` is denied while + `SOME_VAR=abc uloop ...` is not, and the same variable expanded into an *argument* is fine. An + entry that spells the variable out restores it; both of these were verified: + + "ULOOP_PROJECT_RUNNER_PATH=* uloop *", + "ULOOP_PROJECT_RUNNER_PATH=* dist/*/uloop *" + + That is the shape remedy 3 uses. Without such an entry, write the value literally or `export` + it as a separate command first. + +One model accounts for every row: the glob is matched against the command text *before* +expansion, and only when that fails is a literal env assignment stripped and the remainder +re-matched — which a `"$V"` value cannot be, since its value is not known. Treat that as a rule +of thumb for predicting new cases, not as fact: it is inferred from the measurements above, not +from Claude Code's implementation. Measure before relying on a shape that is not in the table. + +The denied row also reproduces the misdiagnosis this document warns about. Because the installed +`uloop` resolves a *released* project runner, its failure is not the refusal report above but +`... i/o timeout` after the retry window — the pre-fix behaviour described under Symptom. Seeing +`i/o timeout` therefore still means the runner that served the command predates the fix, not +that the Editor is slow. diff --git a/docs/glossary.md b/docs/glossary.md index e66b9264a8..3a6e1be971 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -91,6 +91,12 @@ A generated instruction document that teaches an AI agent how to use a `uloop` c Skill sources live in the package; the copies under `.agents/` and `.claude/` are generated and must not be edited directly. +A skill is also the single source of truth for the tool and parameter descriptions the CLI +prints. `--help` and `uloop list` read the parameter table out of the installed package's +skill at render time, and the embedded catalog (`cli/common/tools/default-tools.json`) is +generated from those same tables. Descriptions are therefore edited in the skill and nowhere +else. + ### Skill target A destination agent environment into which skills are installed (for example Claude Code, diff --git a/docs/project-runner-pin.md b/docs/project-runner-pin.md index 10b6e0671d..5b3e686f7f 100644 --- a/docs/project-runner-pin.md +++ b/docs/project-runner-pin.md @@ -42,6 +42,39 @@ variable to return to normal pin-resolved behavior. Related overrides in the same file: `ULOOP_INSTALL_DIR` (dispatcher install directory) and `ULOOP_CACHE_DIR` (project runner download cache). +## Which runner actually ran + +`resolveDispatcherRealCLI` picks the runner in this order, and **no response field says which +branch won**: + +1. `ULOOP_PROJECT_RUNNER_PATH` (env override). +2. The sibling `uloop-project-runner` next to the dispatcher binary — taken only when the pin's + `projectRunnerVersion` equals the version compiled into *that* dispatcher + (`clicontract.ProjectRunnerVersion()`). +3. `/versions///` (already downloaded). +4. GitHub release download. + +Read this before trusting a dogfooding result. All four points were measured on 2026-07-27. + +- Running `dist//uloop` **without** the override still uses your local build, because + step 2 finds `dist//uloop-project-runner` beside it. Verified by pointing + `ULOOP_CACHE_DIR` at an empty directory: the command succeeded and the directory stayed empty, + so neither the cache nor a download was involved. The override is therefore not what makes a + run local — the dispatcher you typed is. +- Running the installed `uloop` (`~/.local/bin/uloop`, which has no sibling runner) without the + override silently uses whatever step 3 or 4 supplies, i.e. the released runner. +- Step 2 compares against a version baked into the dispatcher at build time, so it stops + applying as soon as the pin moves ahead of your last `scripts/build-go-cli.sh`. That does not + necessarily fail loudly: when the cache already holds that version, step 3 quietly succeeds + with the released runner instead. +- A version number does not identify the binary. A local build and the released runner both + answer `3.0.0-beta.58` to `uloop-project-runner --version` while their SHA-256 differ. (`version` + as a *subcommand* is rejected by the runner on purpose — it belongs to the dispatcher — so only + the flag form reports the runner's own version.) + +Until the dispatcher reports the resolved runner path, comparing SHA-256 by hand is the only +reliable way to establish which binary served a verification run. + ## Pin format discipline The pin evolves additively only — never delete or rename an existing field. diff --git a/scripts/stamp-release-inputs.sh b/scripts/stamp-release-inputs.sh index 4795ef7706..6b9d7d6d63 100755 --- a/scripts/stamp-release-inputs.sh +++ b/scripts/stamp-release-inputs.sh @@ -12,7 +12,10 @@ cd "$ROOT_DIR" # Input selection mirrors the release trigger guard # (cli/release-automation/internal/automation/release_trigger_guard.go): # only package roots imported by shipped binaries count; release-please stamp -# targets such as contract.json and default-tools.json do not. +# targets such as contract.json do not. The embedded tool catalog is the one +# JSON that does count - it is compiled into both binaries and is generated +# from the skill parameter tables, so a tool description change has to reach a +# release. list_shared_common_inputs() { git ls-files -- \ cli/common/go.mod \ @@ -23,6 +26,7 @@ list_shared_common_inputs() { 'cli/common/ipcendpoint/' \ 'cli/common/progress/' \ 'cli/common/project/' \ + 'cli/common/skilldocs/' \ 'cli/common/skillscan/' \ 'cli/common/tooldocs/' \ 'cli/common/tools/' \ @@ -30,7 +34,7 @@ list_shared_common_inputs() { 'cli/common/unityipc/' \ 'cli/common/unityprocess/' \ 'cli/common/vibelog/' | - grep -E '\.go$|\.ps1$|/go\.mod$|/go\.sum$' | + grep -E '\.go$|\.ps1$|/go\.mod$|/go\.sum$|^cli/common/tools/default-tools\.json$' | grep -v '_test\.go$' || true } diff --git a/scripts/sync-tool-docs.sh b/scripts/sync-tool-docs.sh new file mode 100755 index 0000000000..0a799ad104 --- /dev/null +++ b/scripts/sync-tool-docs.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# Regenerate cli/common/tools/default-tools.json descriptions from the package's SKILL.md parameter +# tables. Pass --check to verify instead of write, which is what CI runs. +set -eu + +ROOT_DIR=$(CDPATH= cd "$(dirname "$0")/.." && pwd) + +cd "$ROOT_DIR/cli/release-automation" +exec go run ./cmd/sync-tool-docs --repository-root "$ROOT_DIR" "$@" diff --git a/scripts/test-stamp-release-inputs.sh b/scripts/test-stamp-release-inputs.sh index 57dda3019d..036fa0286e 100755 --- a/scripts/test-stamp-release-inputs.sh +++ b/scripts/test-stamp-release-inputs.sh @@ -21,7 +21,7 @@ create_fixture_repo() { git config user.email "test@example.com" git config user.name "Test User" - mkdir -p cli/common/clicore/subpkg cli/common/clitest cli/common/version/subpkg cli/dispatcher/internal/install/scripts cli/dispatcher/internal/uninstall/scripts cli/project-runner scripts + mkdir -p cli/common/clicore/subpkg cli/common/clitest cli/common/tools cli/common/version/subpkg cli/dispatcher/internal/install/scripts cli/dispatcher/internal/uninstall/scripts cli/project-runner scripts printf 'package clicore\n' > cli/common/clicore/core.go printf 'package subpkg\n' > cli/common/clicore/subpkg/core.go printf 'package clicore\n\n// test-only content\n' > cli/common/clicore/core_test.go @@ -30,6 +30,7 @@ create_fixture_repo() { printf 'package subpkg\n' > cli/common/version/subpkg/compare.go printf 'module example.test/common\n' > cli/common/go.mod printf '{"projectRunnerVersion": "1.0.0"}\n' > cli/common/contract.json + printf '{"tools":[]}\n' > cli/common/tools/default-tools.json printf 'echo install\n' > scripts/install.sh printf 'Write-Host install\n' > scripts/install.ps1 printf 'echo embedded install\n' > cli/dispatcher/internal/install/scripts/install_darwin.sh @@ -130,6 +131,21 @@ if [ "$runner_hash_after_common" = "$runner_hash_initial" ] || exit 1 fi +# Verifies a change to the embedded tool catalog moves both stamps, since it is compiled into both +# binaries even though it is JSON. +commit_fixture_change "$work_dir" "common source change" +printf '{"tools":[{"name":"compile"}]}\n' > "$work_dir/cli/common/tools/default-tools.json" +run_stamp "$work_dir" +runner_hash_after_catalog=$(stamp_hash "$work_dir" cli/project-runner/shared-inputs-stamp.json) +dispatcher_hash_after_catalog=$(stamp_hash "$work_dir" cli/dispatcher/shared-inputs-stamp.json) +if [ "$runner_hash_after_catalog" = "$runner_hash_after_common" ] || + [ "$dispatcher_hash_after_catalog" = "$dispatcher_hash_after_common" ]; then + echo "Expected an embedded tool catalog change to move both stamps." >&2 + exit 1 +fi +runner_hash_after_common=$runner_hash_after_catalog +dispatcher_hash_after_common=$dispatcher_hash_after_catalog + # Verifies a nested shared common Go source change also moves both stamps. commit_fixture_change "$work_dir" "shared common change" printf 'package subpkg\n\nconst changed = true\n' > "$work_dir/cli/common/clicore/subpkg/core.go"