From dab99220ab903ed05e06c11dccc0f098b4eaf6c7 Mon Sep 17 00:00:00 2001 From: hatayama Date: Fri, 24 Jul 2026 10:06:56 +0900 Subject: [PATCH 1/8] Guide unknown-option errors to tool --help instead of --list-options --list-options is not a real command, so following the old NextActions caused a second failure and hid real flags such as --timeout-seconds. Co-authored-by: Cursor --- .../internal/projectrunner/tool_params.go | 2 +- .../projectrunner/tool_params_test.go | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/cli/project-runner/internal/projectrunner/tool_params.go b/cli/project-runner/internal/projectrunner/tool_params.go index abdf6882ea..ce97a96bd6 100644 --- a/cli/project-runner/internal/projectrunner/tool_params.go +++ b/cli/project-runner/internal/projectrunner/tool_params.go @@ -50,7 +50,7 @@ func buildToolParams(args []string, tool clicore.ToolDefinition) (map[string]any Message: "Unknown option for " + tool.Name + ": --" + flag.name, Option: "--" + flag.name, Command: tool.Name, - NextActions: []string{"Run `uloop --list-options " + tool.Name + "` to inspect supported options."}, + NextActions: []string{"Run `uloop " + tool.Name + " --help` to inspect supported options."}, } } diff --git a/cli/project-runner/internal/projectrunner/tool_params_test.go b/cli/project-runner/internal/projectrunner/tool_params_test.go index 2760fd46ca..2ee235ca68 100644 --- a/cli/project-runner/internal/projectrunner/tool_params_test.go +++ b/cli/project-runner/internal/projectrunner/tool_params_test.go @@ -10,6 +10,38 @@ import ( "github.com/hatayama/unity-cli-loop/common/clicore" ) +// Verifies that an unknown option error guides users to ` --help` and never to the non-existent `--list-options`. +func TestBuildToolParamsUnknownOptionNextActionsGuideToHelp(t *testing.T) { + tool := clicore.ToolDefinition{ + Name: "sample-tool", + InputSchema: clicore.InputSchema{ + Properties: map[string]clicore.ToolProperty{ + "Enabled": {Type: "boolean"}, + }, + }, + } + + _, _, err := buildToolParams([]string{"--unknown-flag"}, tool) + if err == nil { + t.Fatal("expected an error for an unknown option") + } + + var argumentError *clierrors.ArgumentError + if !errors.As(err, &argumentError) { + t.Fatalf("expected an *ArgumentError, got %T: %v", err, err) + } + if len(argumentError.NextActions) != 1 { + t.Fatalf("expected exactly one NextAction, got %#v", argumentError.NextActions) + } + want := "Run `uloop sample-tool --help` to inspect supported options." + if argumentError.NextActions[0] != want { + t.Fatalf("NextActions mismatch:\nwant: %q\ngot: %q", want, argumentError.NextActions[0]) + } + if strings.Contains(argumentError.NextActions[0], "--list-options") { + t.Fatalf("NextActions must not mention --list-options: %#v", argumentError.NextActions) + } +} + // Verifies that a string property with an enum accepts an exact-case valid value. func TestConvertValueAcceptsExactCaseEnumValue(t *testing.T) { property := clicore.ToolProperty{Type: "string", Enum: []string{"Play", "Stop", "Pause"}} From fea007f0d36d76fa8baf10eca663fd68e823820b Mon Sep 17 00:00:00 2001 From: hatayama Date: Fri, 24 Jul 2026 10:08:48 +0900 Subject: [PATCH 2/8] Publish tool catalog to .uloop/tools.json on domain reload Package updates left the CLI project cache stale until a manual sync, so real commands could fail with UNKNOWN_COMMAND. Reuse the get-tool-details IPC serialization path so published bytes match uloop sync. Co-authored-by: Cursor --- .../UnityCliLoopApplicationRegistration.cs | 1 + .../Api/ToolCatalogCachePublisher.cs | 53 +++++++++++++++++++ .../Api/ToolCatalogCachePublisher.cs.meta | 11 ++++ .../ToolContracts/UnityCliLoopConstants.cs | 1 + 4 files changed, 66 insertions(+) create mode 100644 Packages/src/Editor/Infrastructure/Api/ToolCatalogCachePublisher.cs create mode 100644 Packages/src/Editor/Infrastructure/Api/ToolCatalogCachePublisher.cs.meta diff --git a/Packages/src/Editor/CompositionRoot/UnityCliLoopApplicationRegistration.cs b/Packages/src/Editor/CompositionRoot/UnityCliLoopApplicationRegistration.cs index eac4b8ec66..c88598b22f 100644 --- a/Packages/src/Editor/CompositionRoot/UnityCliLoopApplicationRegistration.cs +++ b/Packages/src/Editor/CompositionRoot/UnityCliLoopApplicationRegistration.cs @@ -99,6 +99,7 @@ internal UnityCliLoopApplicationServices Register() firstPartyServerLifecycle); UnityCliLoopServerApplicationService applicationService = new(controllerService); controllerService.InitializeForEditorStartup(); + ToolCatalogCachePublisher.Publish(toolRegistrarService); return new UnityCliLoopApplicationServices( domainReloadDetectionService, diff --git a/Packages/src/Editor/Infrastructure/Api/ToolCatalogCachePublisher.cs b/Packages/src/Editor/Infrastructure/Api/ToolCatalogCachePublisher.cs new file mode 100644 index 0000000000..4dc86f69b7 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Api/ToolCatalogCachePublisher.cs @@ -0,0 +1,53 @@ +using System.IO; +using System.Text; + +using Newtonsoft.Json; +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.Application; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// Publishes the tool catalog to .uloop/tools.json so package updates refresh the CLI cache without a manual sync. + /// + internal static class ToolCatalogCachePublisher + { + /// + /// Writes get-tool-details JSON to the project tools cache when the catalog content changed. + /// + public static void Publish(UnityCliLoopToolRegistrarService toolRegistrarService) + { + Debug.Assert(toolRegistrarService != null, "toolRegistrarService must not be null"); + + GetToolDetailsResponse response = GetToolDetailsBridgeCommand.Execute(null, toolRegistrarService); + // Why reuse JsonRpcResponseSerializer: tools.json must match the IPC result bytes that uloop sync writes. + string json = JsonConvert.SerializeObject( + response, + Formatting.None, + JsonRpcResponseSerializer.Settings); + + string cachePath = Path.Combine( + UnityCliLoopConstants.ULOOP_DIR, + UnityCliLoopConstants.ULOOP_TOOLS_CACHE_FILE_NAME); + + if (File.Exists(cachePath)) + { + string existingJson = File.ReadAllText(cachePath); + if (existingJson == json) + { + return; + } + } + + string directory = Path.GetDirectoryName(cachePath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + File.WriteAllText(cachePath, json, new UTF8Encoding(false)); + } + } +} diff --git a/Packages/src/Editor/Infrastructure/Api/ToolCatalogCachePublisher.cs.meta b/Packages/src/Editor/Infrastructure/Api/ToolCatalogCachePublisher.cs.meta new file mode 100644 index 0000000000..6859b79f9e --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Api/ToolCatalogCachePublisher.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a662636e7db4845ac91a8befb0a617d8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs b/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs index 6f47c4c474..711c5a255c 100644 --- a/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs +++ b/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs @@ -56,6 +56,7 @@ public static UnityEditor.PackageManager.PackageInfo PackageInfo // .uloop directory public const string ULOOP_DIR = ".uloop"; public const string ULOOP_TOOL_SETTINGS_FILE_NAME = "settings.tools.json"; + public const string ULOOP_TOOLS_CACHE_FILE_NAME = "tools.json"; public const string ULOOP_PROJECT_RUNNER_PIN_FILE_NAME = "project-runner-pin.json"; // Command name constants From 90d94edd2e7b2be952983c68ac682dc6dd36d43d Mon Sep 17 00:00:00 2001 From: hatayama Date: Fri, 24 Jul 2026 10:11:14 +0900 Subject: [PATCH 3/8] Add uloop version subcommand matching --version output Agents repeatedly tried `uloop version` and hit UNKNOWN_COMMAND because only --version existed. Route the new dispatcher-owned command through the shared version output helpers so text and --json stay identical. Co-authored-by: Cursor --- cli/common/clicore/command_registry.go | 2 ++ cli/common/clicore/command_registry_test.go | 3 ++ .../internal/dispatcher/dispatcher_process.go | 7 ++-- .../internal/dispatcher/dispatcher_test.go | 34 ++++++++++++++++++ .../internal/dispatcher/dispatcher_version.go | 9 +++++ .../internal/dispatcher/help_test.go | 2 ++ .../dispatcher/native_command_options.go | 1 + cli/dispatcher/internal/dispatcher/version.go | 35 +++++++++++++++++++ .../projectrunner/runner_usage_test.go | 1 + 9 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 cli/dispatcher/internal/dispatcher/version.go diff --git a/cli/common/clicore/command_registry.go b/cli/common/clicore/command_registry.go index 094f8832f8..19e56f5f80 100644 --- a/cli/common/clicore/command_registry.go +++ b/cli/common/clicore/command_registry.go @@ -5,6 +5,7 @@ const ( InstallCommandName = "install" UpdateCommandName = "update" UninstallCommandName = "uninstall" + VersionCommandName = "version" SkillsCommandName = "skills" CompileCommandName = "compile" ExecuteDynamicCodeCommandName = "execute-dynamic-code" @@ -39,6 +40,7 @@ var NativeCommands = []NativeCommandEntry{ {Name: InstallCommandName, Description: "Configure the global uloop launcher binary", Owner: DispatcherOwned}, {Name: UpdateCommandName, Description: "Update the global uloop launcher binary", Owner: DispatcherOwned}, {Name: UninstallCommandName, Description: "Remove the global uloop launcher binary", Owner: DispatcherOwned}, + {Name: VersionCommandName, Description: "Show the installed uloop version", Owner: DispatcherOwned}, } // IsDispatcherOwnedCommandName reports whether a native command belongs to the diff --git a/cli/common/clicore/command_registry_test.go b/cli/common/clicore/command_registry_test.go index 534e7852cc..db27303733 100644 --- a/cli/common/clicore/command_registry_test.go +++ b/cli/common/clicore/command_registry_test.go @@ -9,6 +9,7 @@ func TestNativeCommandEntriesDeclareOwners(t *testing.T) { InstallCommandName: DispatcherOwned, UpdateCommandName: DispatcherOwned, UninstallCommandName: DispatcherOwned, + VersionCommandName: DispatcherOwned, SkillsCommandName: DispatcherOwned, CompletionCommand: DispatcherOwned, "list": RunnerOwned, @@ -38,6 +39,7 @@ func TestIsDispatcherOwnedCommandName(t *testing.T) { InstallCommandName, UpdateCommandName, UninstallCommandName, + VersionCommandName, SkillsCommandName, CompletionCommand, } { @@ -77,6 +79,7 @@ func TestIsRunnerOwnedCommandName(t *testing.T) { InstallCommandName, UpdateCommandName, UninstallCommandName, + VersionCommandName, SkillsCommandName, CompletionCommand, CompileCommandName, diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_process.go b/cli/dispatcher/internal/dispatcher/dispatcher_process.go index ab22255783..4386b92fbb 100644 --- a/cli/dispatcher/internal/dispatcher/dispatcher_process.go +++ b/cli/dispatcher/internal/dispatcher/dispatcher_process.go @@ -26,11 +26,11 @@ func tryHandleDispatcherInfoRequest(args []string, stdout io.Writer) (bool, int) return true, 0 } if clicore.IsVersionJSONRequest(args) { - writeDispatcherVersionJSON(stdout) + writeDispatcherVersionOutput(stdout, true) return true, 0 } if clicore.IsVersionRequest(args) { - clicore.WriteLine(stdout, dispatcherVersion) + writeDispatcherVersionOutput(stdout, false) return true, 0 } return false, 0 @@ -73,6 +73,9 @@ func tryHandlePreConnectionRequestWithDeps( if handled, code := tryHandleSkillsRequest(remainingArgs, startPath, projectPath, stdout, stderr); handled { return true, code } + if handled, code := tryHandleVersionRequest(remainingArgs, stdout, stderr); handled { + return true, code + } if clicore.ContainsHelpRequest(commandArgs) { if handled, code := tryHandleCommandHelp(command, startPath, projectPath, stdout, stderr); handled { return true, code diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_test.go b/cli/dispatcher/internal/dispatcher/dispatcher_test.go index 26065e6d47..3b3b44e3f7 100644 --- a/cli/dispatcher/internal/dispatcher/dispatcher_test.go +++ b/cli/dispatcher/internal/dispatcher/dispatcher_test.go @@ -286,6 +286,40 @@ func TestRunDispatcherVersionUsesDispatcherVersion(t *testing.T) { } } +func TestRunDispatcherVersionSubcommandMatchesFlagVersion(t *testing.T) { + // Verifies `uloop version` returns the same text as `uloop --version`. + t.Chdir(t.TempDir()) + + var flagStdout bytes.Buffer + var subcommandStdout bytes.Buffer + flagCode := RunDispatcher(context.Background(), []string{"--version"}, &flagStdout, io.Discard) + subcommandCode := RunDispatcher(context.Background(), []string{clicore.VersionCommandName}, &subcommandStdout, io.Discard) + + if flagCode != 0 || subcommandCode != 0 { + t.Fatalf("version exit codes mismatch: flag=%d subcommand=%d", flagCode, subcommandCode) + } + if flagStdout.String() != subcommandStdout.String() { + t.Fatalf("version output mismatch:\nflag: %q\nsubcommand: %q", flagStdout.String(), subcommandStdout.String()) + } +} + +func TestRunDispatcherVersionSubcommandJSONMatchesFlagVersionJSON(t *testing.T) { + // Verifies `uloop version --json` returns the same JSON as `uloop --version --json`. + t.Chdir(t.TempDir()) + + var flagStdout bytes.Buffer + var subcommandStdout bytes.Buffer + flagCode := RunDispatcher(context.Background(), []string{"--version", "--json"}, &flagStdout, io.Discard) + subcommandCode := RunDispatcher(context.Background(), []string{clicore.VersionCommandName, "--json"}, &subcommandStdout, io.Discard) + + if flagCode != 0 || subcommandCode != 0 { + t.Fatalf("version --json exit codes mismatch: flag=%d subcommand=%d", flagCode, subcommandCode) + } + if flagStdout.String() != subcommandStdout.String() { + t.Fatalf("version --json output mismatch:\nflag: %q\nsubcommand: %q", flagStdout.String(), subcommandStdout.String()) + } +} + func TestResolveDispatcherRealCLIRejectsInvalidProjectRunnerVersion(t *testing.T) { // Verifies project pins cannot escape the dispatcher cache through projectRunnerVersion path segments. t.Setenv(nativepath.CacheDirEnvName, t.TempDir()) diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_version.go b/cli/dispatcher/internal/dispatcher/dispatcher_version.go index eaf2bf43f1..a15a92fbde 100644 --- a/cli/dispatcher/internal/dispatcher/dispatcher_version.go +++ b/cli/dispatcher/internal/dispatcher/dispatcher_version.go @@ -10,6 +10,15 @@ import ( var dispatcherVersion = dispatchercontract.DispatcherCurrent.DispatcherVersion +// writeDispatcherVersionOutput writes the same payload as --version / --version --json. +func writeDispatcherVersionOutput(stdout io.Writer, asJSON bool) { + if asJSON { + writeDispatcherVersionJSON(stdout) + return + } + clicore.WriteLine(stdout, dispatcherVersion) +} + func writeDispatcherVersionJSON(stdout io.Writer) { content, err := json.Marshal(map[string]any{ "DispatcherVersion": dispatcherVersion, diff --git a/cli/dispatcher/internal/dispatcher/help_test.go b/cli/dispatcher/internal/dispatcher/help_test.go index baada23a83..2c128c6217 100644 --- a/cli/dispatcher/internal/dispatcher/help_test.go +++ b/cli/dispatcher/internal/dispatcher/help_test.go @@ -28,6 +28,7 @@ func TestPrintLauncherHelpListsNativeCommandsAndLiveToolGuidance(t *testing.T) { " list", " skills", " uninstall", + " version", "Unity tool commands are project-specific.", "does not include the full command list", "uloop --project-path /path/to/project --help", @@ -68,6 +69,7 @@ func TestPrintProjectLocalHelpListsNativeCommandsAndLiveToolGuidance(t *testing. " list", " sync", " uninstall", + " version", "Unity tool commands are project-specific.", "does not include the full command list", "uloop --project-path /path/to/project --help", diff --git a/cli/dispatcher/internal/dispatcher/native_command_options.go b/cli/dispatcher/internal/dispatcher/native_command_options.go index 58afad825f..0667ae3d95 100644 --- a/cli/dispatcher/internal/dispatcher/native_command_options.go +++ b/cli/dispatcher/internal/dispatcher/native_command_options.go @@ -22,4 +22,5 @@ var nativeCommandOptions = map[string][]string{ }, clicore.InstallCommandName: {"--" + installDirFlagName}, clicore.UpdateCommandName: {"--" + updateToVersionFlagName}, + clicore.VersionCommandName: {"--json"}, } diff --git a/cli/dispatcher/internal/dispatcher/version.go b/cli/dispatcher/internal/dispatcher/version.go new file mode 100644 index 0000000000..4d767749da --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/version.go @@ -0,0 +1,35 @@ +package dispatcher + +import ( + "io" + + clierrors "github.com/hatayama/unity-cli-loop/common/errors" + + "github.com/hatayama/unity-cli-loop/common/clicore" +) + +func tryHandleVersionRequest(args []string, stdout io.Writer, stderr io.Writer) (bool, int) { + if len(args) == 0 || args[0] != clicore.VersionCommandName { + return false, 0 + } + if clicore.ContainsHelpRequest(args[1:]) { + printNativeSingleCommandHelp(clicore.VersionCommandName, stdout) + return true, 0 + } + if len(args) == 1 { + writeDispatcherVersionOutput(stdout, false) + return true, 0 + } + if len(args) == 2 && args[1] == "--json" { + writeDispatcherVersionOutput(stdout, true) + return true, 0 + } + + clierrors.WriteClassifiedError(stderr, &clierrors.ArgumentError{ + Message: "Unknown version option: " + args[1], + Option: args[1], + Command: clicore.VersionCommandName, + NextActions: []string{"Run `uloop version` or `uloop version --json`."}, + }, clierrors.ErrorContext{Command: clicore.VersionCommandName}) + return true, 1 +} diff --git a/cli/project-runner/internal/projectrunner/runner_usage_test.go b/cli/project-runner/internal/projectrunner/runner_usage_test.go index 238d60b490..514bfefcba 100644 --- a/cli/project-runner/internal/projectrunner/runner_usage_test.go +++ b/cli/project-runner/internal/projectrunner/runner_usage_test.go @@ -17,6 +17,7 @@ func TestRunProjectLocalRejectsDispatcherOwnedCommands(t *testing.T) { "install", "update", "uninstall", + "version", "skills", "completion", } { From e880d2a3fc5199b2b74c312719dab41b4d99b194 Mon Sep 17 00:00:00 2001 From: hatayama Date: Fri, 24 Jul 2026 10:12:25 +0900 Subject: [PATCH 4/8] Accept Resume as a Play alias in control-play-mode Pause-point workflows already say \"resume\" (--resume-play), but the control-play-mode action enum only had Play, so agents hit invalid-argument errors. Add Resume at the enum tail and document the alias in skills. Co-authored-by: Cursor --- .agents/skills/uloop-control-play-mode/SKILL.md | 3 ++- .claude/skills/uloop-control-play-mode/SKILL.md | 3 ++- .../FirstPartyTools/ControlPlayMode/ControlPlayModeSchema.cs | 3 ++- .../ControlPlayMode/ControlPlayModeUseCase.cs | 1 + .../Editor/FirstPartyTools/ControlPlayMode/Skill/SKILL.md | 3 ++- cli/common/tools/default-tools.json | 5 +++-- 6 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.agents/skills/uloop-control-play-mode/SKILL.md b/.agents/skills/uloop-control-play-mode/SKILL.md index a5057509c1..3294d7a23b 100644 --- a/.agents/skills/uloop-control-play-mode/SKILL.md +++ b/.agents/skills/uloop-control-play-mode/SKILL.md @@ -18,7 +18,7 @@ uloop control-play-mode [options] | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | string | `Play` | Action to perform: `Play`, `Stop`, `Pause`, `Step`, `Status` | +| `--action` | string | `Play` | Action to perform: `Play`, `Stop`, `Pause`, `Step`, `Status`, `Resume` (alias of `Play`) | | `--timeout-seconds` | integer | `180` | Maximum seconds to wait for the requested play mode state | ## Output @@ -37,6 +37,7 @@ Returns JSON with the current play mode state: - Stop on an already-stopped Editor sets `Changed: false`, `WasAlreadyStopped: true` - `Play` on an Editor that is already playing is a no-op: it sets `Changed: false` and leaves the current session (its accumulated state, spawned objects, progress) untouched instead of restarting it. If you need a clean state for verification, explicitly `Stop` then `Play` rather than relying on `Play` alone to reset anything. - `Play` while Play Mode is paused resumes the same session: it sets `Changed: true`, `ResumedFromPause: true`, and `Message: "Play mode resumed"` — the session is not restarted. +- `Resume` is an alias of `Play`, accepted because pause-point workflows describe this operation as "resume" (`--resume-play`, "Clearing resumes Play Mode"). It behaves identically to `Play` in every state, including starting Play Mode when stopped. - `Step` advances exactly one frame and leaves PlayMode paused (the Editor's Next Frame button); it is independent of `Time.timeScale` and requires PlayMode to be running - The command waits for the requested state before returning. Increase `--timeout-seconds` for projects with slow PlayMode entry. - Before relying on PlayMode behavior as verification evidence, check `uloop get-logs --log-type Error` for pre-existing errors. An error already present when PlayMode starts can otherwise be mistaken for one caused by the action under test. diff --git a/.claude/skills/uloop-control-play-mode/SKILL.md b/.claude/skills/uloop-control-play-mode/SKILL.md index a5057509c1..3294d7a23b 100644 --- a/.claude/skills/uloop-control-play-mode/SKILL.md +++ b/.claude/skills/uloop-control-play-mode/SKILL.md @@ -18,7 +18,7 @@ uloop control-play-mode [options] | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | string | `Play` | Action to perform: `Play`, `Stop`, `Pause`, `Step`, `Status` | +| `--action` | string | `Play` | Action to perform: `Play`, `Stop`, `Pause`, `Step`, `Status`, `Resume` (alias of `Play`) | | `--timeout-seconds` | integer | `180` | Maximum seconds to wait for the requested play mode state | ## Output @@ -37,6 +37,7 @@ Returns JSON with the current play mode state: - Stop on an already-stopped Editor sets `Changed: false`, `WasAlreadyStopped: true` - `Play` on an Editor that is already playing is a no-op: it sets `Changed: false` and leaves the current session (its accumulated state, spawned objects, progress) untouched instead of restarting it. If you need a clean state for verification, explicitly `Stop` then `Play` rather than relying on `Play` alone to reset anything. - `Play` while Play Mode is paused resumes the same session: it sets `Changed: true`, `ResumedFromPause: true`, and `Message: "Play mode resumed"` — the session is not restarted. +- `Resume` is an alias of `Play`, accepted because pause-point workflows describe this operation as "resume" (`--resume-play`, "Clearing resumes Play Mode"). It behaves identically to `Play` in every state, including starting Play Mode when stopped. - `Step` advances exactly one frame and leaves PlayMode paused (the Editor's Next Frame button); it is independent of `Time.timeScale` and requires PlayMode to be running - The command waits for the requested state before returning. Increase `--timeout-seconds` for projects with slow PlayMode entry. - Before relying on PlayMode behavior as verification evidence, check `uloop get-logs --log-type Error` for pre-existing errors. An error already present when PlayMode starts can otherwise be mistaken for one caused by the action under test. diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeSchema.cs b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeSchema.cs index 33c295299c..b33ae64e64 100644 --- a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeSchema.cs +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeSchema.cs @@ -11,7 +11,8 @@ public enum PlayModeAction Stop = 1, Pause = 2, Step = 3, - Status = 4 + Status = 4, + Resume = 5 } /// diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeUseCase.cs b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeUseCase.cs index 19f288d437..5b17dae7b2 100644 --- a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeUseCase.cs @@ -96,6 +96,7 @@ private ControlPlayModeActionResult ExecuteRequestedPlayModeAction(PlayModeActio switch (action) { case PlayModeAction.Play: + case PlayModeAction.Resume: return ExecutePlayModeStart(wasPaused, wasPlaying); case PlayModeAction.Stop: diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/SKILL.md index a5057509c1..3294d7a23b 100644 --- a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/SKILL.md @@ -18,7 +18,7 @@ uloop control-play-mode [options] | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | string | `Play` | Action to perform: `Play`, `Stop`, `Pause`, `Step`, `Status` | +| `--action` | string | `Play` | Action to perform: `Play`, `Stop`, `Pause`, `Step`, `Status`, `Resume` (alias of `Play`) | | `--timeout-seconds` | integer | `180` | Maximum seconds to wait for the requested play mode state | ## Output @@ -37,6 +37,7 @@ Returns JSON with the current play mode state: - Stop on an already-stopped Editor sets `Changed: false`, `WasAlreadyStopped: true` - `Play` on an Editor that is already playing is a no-op: it sets `Changed: false` and leaves the current session (its accumulated state, spawned objects, progress) untouched instead of restarting it. If you need a clean state for verification, explicitly `Stop` then `Play` rather than relying on `Play` alone to reset anything. - `Play` while Play Mode is paused resumes the same session: it sets `Changed: true`, `ResumedFromPause: true`, and `Message: "Play mode resumed"` — the session is not restarted. +- `Resume` is an alias of `Play`, accepted because pause-point workflows describe this operation as "resume" (`--resume-play`, "Clearing resumes Play Mode"). It behaves identically to `Play` in every state, including starting Play Mode when stopped. - `Step` advances exactly one frame and leaves PlayMode paused (the Editor's Next Frame button); it is independent of `Time.timeScale` and requires PlayMode to be running - The command waits for the requested state before returning. Increase `--timeout-seconds` for projects with slow PlayMode entry. - Before relying on PlayMode behavior as verification evidence, check `uloop get-logs --log-type Error` for pre-existing errors. An error already present when PlayMode starts can otherwise be mistaken for one caused by the action under test. diff --git a/cli/common/tools/default-tools.json b/cli/common/tools/default-tools.json index fc56eba532..e174a6f230 100644 --- a/cli/common/tools/default-tools.json +++ b/cli/common/tools/default-tools.json @@ -326,13 +326,14 @@ "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", + "description": "Action to perform: Play/Stop/Pause/Step/Status/Resume (alias of Play)", "enum": [ "Play", "Stop", "Pause", "Step", - "Status" + "Status", + "Resume" ], "default": "Play" }, From e1fad245fdce9711c98645a4beacfbd7bf132c1f Mon Sep 17 00:00:00 2001 From: hatayama Date: Fri, 24 Jul 2026 10:13:48 +0900 Subject: [PATCH 5/8] Propagate enable resolved fields into pause-point --await hits E2E records need both which line was armed and what was captured in one response. Copy ResolvedLine/ResolvedLineText/ResolvedMethod/SnapshotTiming from the enable payload using the same enable-time carry pattern as Warning. Co-authored-by: Cursor --- .../projectrunner/pause_point_enable.go | 48 ++++-- .../projectrunner/pause_point_enable_test.go | 159 ++++++++++++++++++ .../projectrunner/pause_point_types.go | 9 + 3 files changed, 206 insertions(+), 10 deletions(-) diff --git a/cli/project-runner/internal/projectrunner/pause_point_enable.go b/cli/project-runner/internal/projectrunner/pause_point_enable.go index 3ea77d6070..31d786d6ff 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_enable.go +++ b/cli/project-runner/internal/projectrunner/pause_point_enable.go @@ -323,18 +323,41 @@ func runEnablePausePointAndAwait( resumePlay: resumePlay, } - return runPausePointWaitAfterEnable(ctx, connection, waitOptions, enableResponse.Warning, stdout, stderr) + return runPausePointWaitAfterEnable( + ctx, + connection, + waitOptions, + enablePausePointPropagatedFields{ + Warning: enableResponse.Warning, + ResolvedLine: enableResponse.ResolvedLine, + ResolvedLineText: enableResponse.ResolvedLineText, + ResolvedMethod: enableResponse.ResolvedMethod, + SnapshotTiming: enableResponse.SnapshotTiming, + }, + stdout, + stderr, + ) +} + +// enablePausePointPropagatedFields carries enable-time fields into the --await hit response, +// matching how Warning alone used to be forwarded from runEnablePausePointAndAwait. +type enablePausePointPropagatedFields struct { + Warning string + ResolvedLine int + ResolvedLineText string + ResolvedMethod string + SnapshotTiming string } // runPausePointWaitAfterEnable mirrors runWaitForPausePoint's response shaping (the shared -// helpers it calls are reused as-is) but also folds the enable-time Warning (for example the -// physics-callback cached-dispatch warning) into the merged response, since --await must not -// drop evidence the plain enable-pause-point response would have carried. +// helpers it calls are reused as-is) but also folds enable-time fields (Warning and the +// file:line resolution details) into the merged response, since --await must not drop evidence +// the plain enable-pause-point response would have carried. func runPausePointWaitAfterEnable( ctx context.Context, connection unityipc.Connection, options waitForPausePointOptions, - enableWarning string, + enableFields enablePausePointPropagatedFields, stdout io.Writer, stderr io.Writer, ) int { @@ -356,6 +379,11 @@ func runPausePointWaitAfterEnable( response.TriggerResult = triggerResult response.ResumePlayResult = resumeResult + // Status polls never carry these fields today; always prefer the enable-time values. + response.ResolvedLine = enableFields.ResolvedLine + response.ResolvedLineText = enableFields.ResolvedLineText + response.ResolvedMethod = enableFields.ResolvedMethod + response.SnapshotTiming = enableFields.SnapshotTiming response = filterPausePointCapturedVariableHistory(response) response = filterPausePointCapturedVariablesByName(response, options.capturedVariableNames) response = applyPausePointCapturedVariablesMode(response, options.capturedVariablesMode) @@ -367,11 +395,11 @@ func runPausePointWaitAfterEnable( payload = pausePointWaitResult{ pausePointStatusResponse: response, MatchingLogs: logs.Logs, - Warning: joinPausePointWarnings(enableWarning, buildPausePointWarning(logs, response.HitCount)), + Warning: joinPausePointWarnings(enableFields.Warning, buildPausePointWarning(logs, response.HitCount)), Expectations: expectations, AllExpectationsPassed: pausePointAllExpectationsPassedPointer(expectations), } - case enableWarning != "" || len(expectations) > 0: + 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 @@ -384,7 +412,7 @@ func runPausePointWaitAfterEnable( AllExpectationsPassed *bool `json:"AllExpectationsPassed,omitempty"` }{ pausePointStatusResponse: response, - Warning: enableWarning, + Warning: enableFields.Warning, Expectations: expectations, AllExpectationsPassed: pausePointAllExpectationsPassedPointer(expectations), } @@ -407,8 +435,8 @@ func runPausePointWaitAfterEnable( waitErr := pausePointWaitError(connection.ProjectRoot, options, response, state) waitErr.Command = pausePointEnableCommandName - if enableWarning != "" { - waitErr.Details["EnableWarning"] = enableWarning + if enableFields.Warning != "" { + waitErr.Details["EnableWarning"] = enableFields.Warning } if triggerResult != nil { waitErr.Details["TriggerResult"] = triggerResult diff --git a/cli/project-runner/internal/projectrunner/pause_point_enable_test.go b/cli/project-runner/internal/projectrunner/pause_point_enable_test.go index 637b678825..6b512addaf 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_enable_test.go +++ b/cli/project-runner/internal/projectrunner/pause_point_enable_test.go @@ -211,6 +211,165 @@ func TestRunEnablePausePointCommandAwaitsAfterSuccessfulEnable(t *testing.T) { } } +// Verifies file:line enable --await copies ResolvedLine / ResolvedLineText / ResolvedMethod / +// SnapshotTiming from the enable response into the await hit payload. +func TestRunEnablePausePointCommandAwaitPropagatesFileLineResolvedFields(t *testing.T) { + originalQuery := queryPausePointStatus + originalPoll := pausePointStatusPoll + originalFetch := fetchMatchingLogs + pausePointStatusPoll = time.Millisecond + t.Cleanup(func() { + queryPausePointStatus = originalQuery + pausePointStatusPoll = originalPoll + fetchMatchingLogs = originalFetch + }) + + statusResponses := []pausePointStatusResponse{ + {Id: "Assets/Foo.cs:42", Status: pausePointStatusEnabled, IsEnabled: true}, + {Id: "Assets/Foo.cs:42", Status: pausePointStatusHit, IsHit: true, HitCount: 1}, + } + statusCallCount := 0 + queryPausePointStatus = func(ctx context.Context, connection unityipc.Connection, id string) (pausePointStatusResponse, error) { + response := statusResponses[statusCallCount] + statusCallCount++ + return response, nil + } + fetchMatchingLogs = func( + ctx context.Context, + connection unityipc.Connection, + searchText string, + maxCount int, + ) (pausePointMatchingLogsResult, error) { + return pausePointMatchingLogsResult{SearchText: searchText, Logs: []pausePointMatchingLog{}}, nil + } + + listener := newLoopbackIpcListener(t) + enableRequests := make(chan map[string]any, 1) + serverErr := make(chan error, 1) + go serveSingleIPCResponse( + listener, + pausePointEnableCommandName, + enableRequests, + serverErr, + `{"Success":true,"Id":"Assets/Foo.cs:42","Status":"Enabled","IsEnabled":true,"TimeoutSeconds":30,"ResolvedLine":42,"ResolvedLineText":" DoJump();","ResolvedMethod":"Player.Update","SnapshotTiming":"OnEnter"}`, + ) + + connection := unityipc.Connection{ + Endpoint: unityipc.Endpoint{ + Network: listener.Addr().Network(), + Address: listener.Addr().String(), + }, + ProjectRoot: t.TempDir(), + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runEnablePausePointCommand( + context.Background(), + connection, + []string{"--file", "Assets/Foo.cs", "--line", "42", "--await"}, + t.TempDir(), + &stdout, + &stderr) + + if code != 0 { + t.Fatalf("expected success, got %d with stderr %s", code, stderr.String()) + } + + var response pausePointWaitResult + if err := json.Unmarshal(stdout.Bytes(), &response); err != nil { + t.Fatalf("failed to decode stdout: %v\n%s", err, stdout.String()) + } + if response.ResolvedLine != 42 { + t.Fatalf("ResolvedLine mismatch: %#v", response) + } + if response.ResolvedLineText != " DoJump();" { + t.Fatalf("ResolvedLineText mismatch: %#v", response) + } + if response.ResolvedMethod != "Player.Update" { + t.Fatalf("ResolvedMethod mismatch: %#v", response) + } + if response.SnapshotTiming != "OnEnter" { + t.Fatalf("SnapshotTiming mismatch: %#v", response) + } +} + +// Verifies method-name enable --await omits ResolvedLine / ResolvedLineText / ResolvedMethod / +// SnapshotTiming when the enable response did not set them. +func TestRunEnablePausePointCommandAwaitOmitsResolvedFieldsForMethodArm(t *testing.T) { + originalQuery := queryPausePointStatus + originalPoll := pausePointStatusPoll + originalFetch := fetchMatchingLogs + pausePointStatusPoll = time.Millisecond + t.Cleanup(func() { + queryPausePointStatus = originalQuery + pausePointStatusPoll = originalPoll + fetchMatchingLogs = originalFetch + }) + + statusResponses := []pausePointStatusResponse{ + {Id: "jump", Status: pausePointStatusEnabled, IsEnabled: true}, + {Id: "jump", Status: pausePointStatusHit, IsHit: true, HitCount: 1}, + } + statusCallCount := 0 + queryPausePointStatus = func(ctx context.Context, connection unityipc.Connection, id string) (pausePointStatusResponse, error) { + response := statusResponses[statusCallCount] + statusCallCount++ + return response, nil + } + fetchMatchingLogs = func( + ctx context.Context, + connection unityipc.Connection, + searchText string, + maxCount int, + ) (pausePointMatchingLogsResult, error) { + return pausePointMatchingLogsResult{SearchText: searchText, Logs: []pausePointMatchingLog{}}, nil + } + + listener := newLoopbackIpcListener(t) + enableRequests := make(chan map[string]any, 1) + serverErr := make(chan error, 1) + go serveSingleIPCResponse( + listener, + pausePointEnableCommandName, + enableRequests, + serverErr, + `{"Success":true,"Id":"jump","Status":"Enabled","IsEnabled":true,"TimeoutSeconds":30}`, + ) + + connection := unityipc.Connection{ + Endpoint: unityipc.Endpoint{ + Network: listener.Addr().Network(), + Address: listener.Addr().String(), + }, + ProjectRoot: t.TempDir(), + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runEnablePausePointCommand( + context.Background(), + connection, + []string{"--id", "jump", "--await"}, + t.TempDir(), + &stdout, + &stderr) + + if code != 0 { + t.Fatalf("expected success, got %d with stderr %s", code, stderr.String()) + } + + var raw map[string]any + if err := json.Unmarshal(stdout.Bytes(), &raw); err != nil { + t.Fatalf("failed to decode stdout: %v\n%s", err, stdout.String()) + } + for _, field := range []string{"ResolvedLine", "ResolvedLineText", "ResolvedMethod", "SnapshotTiming"} { + if _, present := raw[field]; present { + t.Fatalf("method-name await hit must omit %s, got %#v", field, raw) + } + } +} + // Verifies a log fetch failure after --await never turns a successful hit into an error, and // omits MatchingLogs entirely (like the plain await-pause-point path) rather than emitting an // empty array, so "empty array" keeps meaning "fetch succeeded with no matches" for both commands. diff --git a/cli/project-runner/internal/projectrunner/pause_point_types.go b/cli/project-runner/internal/projectrunner/pause_point_types.go index f83953a1c8..874ca1174b 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_types.go +++ b/cli/project-runner/internal/projectrunner/pause_point_types.go @@ -36,6 +36,15 @@ type pausePointStatusResponse struct { // which only the enable-pause-point --await path (pause_point_enable.go) currently reads. Warning string `json:"Warning,omitempty"` + // ResolvedLine / ResolvedLineText / ResolvedMethod / SnapshotTiming are copied from the + // enable-pause-point response on the --await hit path so a single await payload records + // both which source line was armed and what was captured. Method-name arms leave them empty + // on the Unity side, so omitempty keeps the historical await schema unchanged for those cases. + ResolvedLine int `json:"ResolvedLine,omitempty"` + ResolvedLineText string `json:"ResolvedLineText,omitempty"` + ResolvedMethod string `json:"ResolvedMethod,omitempty"` + SnapshotTiming string `json:"SnapshotTiming,omitempty"` + // CapturedVariableNameFilterNoMatch is set by the CLI, not Unity, when // --captured-variable-names was passed but none of the requested names matched any // captured variable (current or history), so an agent doesn't mistake an empty From 4725207a39c43ac36e0a394a498116ac72f25a7f Mon Sep 17 00:00:00 2001 From: hatayama Date: Fri, 24 Jul 2026 10:14:02 +0900 Subject: [PATCH 6/8] Stamp shared release inputs after cli/common changes P3 and P4 touched non-test cli/common sources, so refresh the dispatcher and project-runner shared-input stamps for check-release-triggers. Co-authored-by: Cursor --- cli/dispatcher/shared-inputs-stamp.json | 2 +- cli/project-runner/shared-inputs-stamp.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/dispatcher/shared-inputs-stamp.json b/cli/dispatcher/shared-inputs-stamp.json index 04096ea454..cca25d7a30 100644 --- a/cli/dispatcher/shared-inputs-stamp.json +++ b/cli/dispatcher/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "94226bfd226cb7dae313f0f318b920d8835c0254" + "sharedInputsHash": "bc86212bf2f189b735e14300a5580cacec379e65" } diff --git a/cli/project-runner/shared-inputs-stamp.json b/cli/project-runner/shared-inputs-stamp.json index b72a40cfb1..70ea0ed4ad 100644 --- a/cli/project-runner/shared-inputs-stamp.json +++ b/cli/project-runner/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "5faa8097cbbd0ba6991962f620fd18a68fbb2168" + "sharedInputsHash": "74b6da6b39dd0b0a13391fd355f14d3a373d7678" } From 0d5f95aaecda1359f2b0481975302e7e5559c8db Mon Sep 17 00:00:00 2001 From: hatayama Date: Fri, 24 Jul 2026 10:21:16 +0900 Subject: [PATCH 7/8] Keep detailed control-play-mode Action descriptions when adding Resume The previous schema update replaced the per-action explanations with a short enum list. Restore the original wording and append Resume as an alias of Play. Co-authored-by: Cursor --- cli/common/tools/default-tools.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/common/tools/default-tools.json b/cli/common/tools/default-tools.json index e174a6f230..78485a0d31 100644 --- a/cli/common/tools/default-tools.json +++ b/cli/common/tools/default-tools.json @@ -326,7 +326,7 @@ "properties": { "Action": { "type": "string", - "description": "Action to perform: Play/Stop/Pause/Step/Status/Resume (alias of Play)", + "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 (resumes the paused session)", "enum": [ "Play", "Stop", From 3d4e79a5dea5ed4a95ac3c33fff2408000976c53 Mon Sep 17 00:00:00 2001 From: hatayama Date: Fri, 24 Jul 2026 10:32:37 +0900 Subject: [PATCH 8/8] Normalize Resume to Play in control-play-mode wait path Resume skipped state waiting and compile-block handling because wait logic only recognized Play. Map Resume once at the wait entrypoints, keep the raw RequestedAction in errors, and align StatusOnly/C# gates plus schema/version error wording from review feedback. Co-authored-by: Cursor --- .../Editor/ControlPlayModeUseCaseTests.cs | 30 ++++ .../ControlPlayMode/ControlPlayModeUseCase.cs | 4 +- cli/common/tools/default-tools.json | 2 +- .../internal/dispatcher/dispatcher_test.go | 19 +++ cli/dispatcher/internal/dispatcher/version.go | 10 +- .../projectrunner/control_play_mode_wait.go | 24 ++- .../control_play_mode_wait_test.go | 156 ++++++++++++++++++ 7 files changed, 234 insertions(+), 11 deletions(-) diff --git a/Assets/Tests/Editor/ControlPlayModeUseCaseTests.cs b/Assets/Tests/Editor/ControlPlayModeUseCaseTests.cs index 54c4f52839..7c6be8c115 100644 --- a/Assets/Tests/Editor/ControlPlayModeUseCaseTests.cs +++ b/Assets/Tests/Editor/ControlPlayModeUseCaseTests.cs @@ -68,6 +68,36 @@ public async Task ExecuteAsync_WhenStatusOnlyPlayBlockedByCompileErrors_ReturnsS Assert.That(response.Message, Is.EqualTo("Play mode could not start because Unity has compiler errors.")); } + [Test] + public async Task ExecuteAsync_WhenStatusOnlyResumeBlockedByCompileErrors_ReturnsSavedDiagnostics() + { + // Verifies Resume is treated as a Play alias for StatusOnly compile-error blocking. + Assert.That(EditorApplication.isPlaying, Is.False); + ControlPlayModeCompileError[] compileErrors = + { + new ControlPlayModeCompileError + { + Message = "CS1525: invalid expression", + File = "Assets/Scripts/Sample.cs", + Line = 3 + } + }; + ControlPlayModeUseCase useCase = new ControlPlayModeUseCase( + new StubCompilationFailureProvider(compileErrors), + new StubCompilationFailureGate(true)); + ControlPlayModeSchema schema = new ControlPlayModeSchema + { + Action = PlayModeAction.Resume, + StatusOnly = true, + }; + + ControlPlayModeResponse response = await useCase.ExecuteAsync(schema, CancellationToken.None); + + Assert.That(response.BlockedByCompileErrors, Is.True); + Assert.That(response.CompileErrorCount, Is.EqualTo(1)); + Assert.That(response.Message, Is.EqualTo("Play mode could not start because Unity has compiler errors.")); + } + [Test] public async Task ExecuteAsync_WhenStatusOnlyStopAndCompileFailed_ReturnsCurrentPlayModeState() { diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeUseCase.cs b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeUseCase.cs index 5b17dae7b2..ce52b98c3c 100644 --- a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeUseCase.cs @@ -139,7 +139,9 @@ private ControlPlayModeActionResult CreateStatusActionResult() private bool ShouldBlockPlayForCompileErrors(PlayModeAction action, bool isPlaying) { - return action == PlayModeAction.Play && + // Why Resume too: it is a Play alias, including for StatusOnly IPC probes that bypass CLI wait normalization. + bool isPlayOrResume = action == PlayModeAction.Play || action == PlayModeAction.Resume; + return isPlayOrResume && !isPlaying && _compilationFailureGate.HasScriptCompilationFailed(); } diff --git a/cli/common/tools/default-tools.json b/cli/common/tools/default-tools.json index 78485a0d31..10ee3bc3e2 100644 --- a/cli/common/tools/default-tools.json +++ b/cli/common/tools/default-tools.json @@ -326,7 +326,7 @@ "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 (resumes the paused session)", + "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", "enum": [ "Play", "Stop", diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_test.go b/cli/dispatcher/internal/dispatcher/dispatcher_test.go index 3b3b44e3f7..9dfebaf511 100644 --- a/cli/dispatcher/internal/dispatcher/dispatcher_test.go +++ b/cli/dispatcher/internal/dispatcher/dispatcher_test.go @@ -320,6 +320,25 @@ func TestRunDispatcherVersionSubcommandJSONMatchesFlagVersionJSON(t *testing.T) } } +func TestRunDispatcherVersionSubcommandReportsTrailingUnknownOption(t *testing.T) { + // Verifies `uloop version --json extra` reports the trailing argument, not --json itself. + t.Chdir(t.TempDir()) + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := RunDispatcher(context.Background(), []string{clicore.VersionCommandName, "--json", "extra"}, &stdout, &stderr) + + if code != 1 { + t.Fatalf("expected failure, got code=%d stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stderr.String(), "Unknown version option: extra") { + t.Fatalf("stderr should report trailing option extra: %s", stderr.String()) + } + if !strings.Contains(stderr.String(), "uloop version --help") { + t.Fatalf("stderr should guide users to version --help: %s", stderr.String()) + } +} + func TestResolveDispatcherRealCLIRejectsInvalidProjectRunnerVersion(t *testing.T) { // Verifies project pins cannot escape the dispatcher cache through projectRunnerVersion path segments. t.Setenv(nativepath.CacheDirEnvName, t.TempDir()) diff --git a/cli/dispatcher/internal/dispatcher/version.go b/cli/dispatcher/internal/dispatcher/version.go index 4d767749da..3d82c42553 100644 --- a/cli/dispatcher/internal/dispatcher/version.go +++ b/cli/dispatcher/internal/dispatcher/version.go @@ -25,11 +25,15 @@ func tryHandleVersionRequest(args []string, stdout io.Writer, stderr io.Writer) return true, 0 } + unknownOption := args[1] + if unknownOption == "--json" && len(args) > 2 { + unknownOption = args[2] + } clierrors.WriteClassifiedError(stderr, &clierrors.ArgumentError{ - Message: "Unknown version option: " + args[1], - Option: args[1], + Message: "Unknown version option: " + unknownOption, + Option: unknownOption, Command: clicore.VersionCommandName, - NextActions: []string{"Run `uloop version` or `uloop version --json`."}, + NextActions: []string{"Run `uloop version --help` to inspect supported options."}, }, clierrors.ErrorContext{Command: clicore.VersionCommandName}) return true, 1 } diff --git a/cli/project-runner/internal/projectrunner/control_play_mode_wait.go b/cli/project-runner/internal/projectrunner/control_play_mode_wait.go index e073fefda4..2a412eb88a 100644 --- a/cli/project-runner/internal/projectrunner/control_play_mode_wait.go +++ b/cli/project-runner/internal/projectrunner/control_play_mode_wait.go @@ -51,7 +51,8 @@ func shouldWaitForControlPlayModeState(command string, params map[string]any) bo if command != controlPlayModeCommandName { return false } - return controlPlayModeActionCanWait(controlPlayModeAction(params)) + action := normalizeControlPlayModeWaitAction(controlPlayModeAction(params)) + return controlPlayModeActionCanWait(action) } func runControlPlayModeWithStateWait( @@ -61,7 +62,9 @@ func runControlPlayModeWithStateWait( stdout io.Writer, stderr io.Writer, ) int { - action := controlPlayModeAction(params) + // Keep the user-facing action for error details; normalize only for wait-path decisions. + requestedAction := controlPlayModeAction(params) + action := normalizeControlPlayModeWaitAction(requestedAction) timeout, timeoutSeconds := controlPlayModeTimeout(params) startedAt := time.Now() spinner := clicore.NewToolSpinner(stderr, controlPlayModeCommandName) @@ -90,13 +93,13 @@ func runControlPlayModeWithStateWait( if initialResponse.BlockedByCompileErrors { spinner.Stop() writeDebugTiming(stderr, controlPlayModeCommandName, time.Since(startedAt), outcome) - clierrors.WriteErrorEnvelope(stderr, controlPlayModeCompileErrorsError(connection.ProjectRoot, action, initialResponse)) + clierrors.WriteErrorEnvelope(stderr, controlPlayModeCompileErrorsError(connection.ProjectRoot, requestedAction, initialResponse)) return 1 } if initialResponse.BlockedByUnsavedChanges { spinner.Stop() writeDebugTiming(stderr, controlPlayModeCommandName, time.Since(startedAt), outcome) - clierrors.WriteErrorEnvelope(stderr, controlPlayModeUnsavedChangesError(connection.ProjectRoot, action, initialResponse)) + clierrors.WriteErrorEnvelope(stderr, controlPlayModeUnsavedChangesError(connection.ProjectRoot, requestedAction, initialResponse)) return 1 } if controlPlayModeStateMatches(action, initialResponse) { @@ -130,12 +133,12 @@ func runControlPlayModeWithStateWait( if !completed { if response.BlockedByCompileErrors { writeDebugTiming(stderr, controlPlayModeCommandName, time.Since(startedAt), outcome) - clierrors.WriteErrorEnvelope(stderr, controlPlayModeCompileErrorsError(connection.ProjectRoot, action, response)) + clierrors.WriteErrorEnvelope(stderr, controlPlayModeCompileErrorsError(connection.ProjectRoot, requestedAction, response)) return 1 } writeDebugTiming(stderr, controlPlayModeCommandName, time.Since(startedAt), outcome) - clierrors.WriteErrorEnvelope(stderr, controlPlayModeWaitTimeoutError(connection.ProjectRoot, action, timeoutSeconds, response)) + clierrors.WriteErrorEnvelope(stderr, controlPlayModeWaitTimeoutError(connection.ProjectRoot, requestedAction, timeoutSeconds, response)) return 1 } @@ -254,6 +257,15 @@ func controlPlayModeAction(params map[string]any) string { return "Play" } +// normalizeControlPlayModeWaitAction maps Resume onto Play for wait-path decisions so the alias +// shares canWait / stateMatches / compile-block behavior without scattering Resume checks. +func normalizeControlPlayModeWaitAction(action string) string { + if strings.EqualFold(action, "Resume") { + return "Play" + } + return action +} + func controlPlayModeTimeout(params map[string]any) (time.Duration, int) { seconds := controlPlayModeTimeoutSeconds(params) return time.Duration(seconds) * time.Second, seconds diff --git a/cli/project-runner/internal/projectrunner/control_play_mode_wait_test.go b/cli/project-runner/internal/projectrunner/control_play_mode_wait_test.go index 3bddb97c5e..5bdf6cd08f 100644 --- a/cli/project-runner/internal/projectrunner/control_play_mode_wait_test.go +++ b/cli/project-runner/internal/projectrunner/control_play_mode_wait_test.go @@ -525,6 +525,162 @@ func TestShouldWaitForControlPlayModeStateSkipsStatusAction(t *testing.T) { } } +// Verifies Resume is treated as a waitable Play alias for state polling. +func TestShouldWaitForControlPlayModeStateIncludesResumeAction(t *testing.T) { + params := map[string]any{controlPlayModeActionParam: "Resume"} + + if !shouldWaitForControlPlayModeState(controlPlayModeCommandName, params) { + t.Fatal("Resume action should enter the state-wait poll loop like Play") + } +} + +// Verifies Resume blocked by compile errors keeps the raw RequestedAction in error details. +func TestRunControlPlayModeWithStateWaitResumeCompileErrorsPreserveRequestedAction(t *testing.T) { + originalPoll := controlPlayModeStatePoll + controlPlayModeStatePoll = time.Millisecond + t.Cleanup(func() { + controlPlayModeStatePoll = originalPoll + }) + + listener := newLoopbackIpcListener(t) + + requests := make(chan map[string]any, 2) + serverErr := make(chan error, 1) + go serveControlPlayModeResponses( + listener, + requests, + serverErr, + []string{ + `{"IsPlaying":false,"IsPaused":false,"BlockedByCompileErrors":true,"CompileErrorCount":1,"CompileErrors":[{"Message":"CS1002: ; expected","File":"Assets/Scripts/Sample.cs","Line":12}],"Message":"Play mode could not start because Unity has compiler errors."}`, + }) + + connection := unityipc.Connection{ + Endpoint: unityipc.Endpoint{ + Network: listener.Addr().Network(), + Address: listener.Addr().String(), + }, + ProjectRoot: t.TempDir(), + } + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := runControlPlayModeWithStateWait( + context.Background(), + connection, + map[string]any{ + controlPlayModeActionParam: "Resume", + controlPlayModeTimeoutParam: 1, + }, + &stdout, + &stderr) + + if code != 1 { + t.Fatalf("expected compile error failure, got %d with stdout %s stderr %s", code, stdout.String(), stderr.String()) + } + + var envelope clierrors.CLIErrorEnvelope + if err := json.Unmarshal(stderr.Bytes(), &envelope); err != nil { + t.Fatalf("stderr is not valid JSON: %v\n%s", err, stderr.String()) + } + if envelope.Error.ErrorCode != clierrors.ErrorCodeControlPlayModeCompileErrors { + t.Fatalf("error code mismatch: %#v", envelope.Error) + } + if envelope.Error.Details["RequestedAction"] != "Resume" { + t.Fatalf("RequestedAction must remain the raw user action Resume, got %#v", envelope.Error.Details["RequestedAction"]) + } + + firstRequest := readControlPlayModeRequest(t, requests) + if firstRequest[controlPlayModeActionParam] != "Resume" { + t.Fatalf("Unity-bound initial request must keep Resume: %#v", firstRequest) + } + if _, ok := firstRequest[controlPlayModeStatusOnlyParam]; ok { + t.Fatalf("initial request should not be status-only: %#v", firstRequest) + } + select { + case secondRequest := <-requests: + t.Fatalf("blocked compile errors should not trigger status polling: %#v", secondRequest) + case <-time.After(100 * time.Millisecond): + } + + select { + case err := <-serverErr: + t.Fatalf("server failed: %v", err) + default: + } +} + +// Verifies Resume waits for the playing state the same way Play does after a stale initial response. +func TestRunControlPlayModeWithStateWaitPollsStatusForResumeLikePlay(t *testing.T) { + originalPoll := controlPlayModeStatePoll + controlPlayModeStatePoll = time.Millisecond + t.Cleanup(func() { + controlPlayModeStatePoll = originalPoll + }) + + listener := newLoopbackIpcListener(t) + + requests := make(chan map[string]any, 2) + serverErr := make(chan error, 1) + go serveControlPlayModeResponses( + listener, + requests, + serverErr, + []string{ + `{"IsPlaying":false,"IsPaused":false,"Message":"Play mode resumed","ResumedFromPause":true,"Changed":true}`, + `{"IsPlaying":true,"IsPaused":false,"Message":"Play mode status"}`, + }) + + connection := unityipc.Connection{ + Endpoint: unityipc.Endpoint{ + Network: listener.Addr().Network(), + Address: listener.Addr().String(), + }, + ProjectRoot: t.TempDir(), + } + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := runControlPlayModeWithStateWait( + context.Background(), + connection, + map[string]any{ + controlPlayModeActionParam: "Resume", + controlPlayModeTimeoutParam: 1, + }, + &stdout, + &stderr) + + if code != 0 { + t.Fatalf("runControlPlayModeWithStateWait failed with %d: %s", code, stderr.String()) + } + + response := controlPlayModeResponse{} + if err := json.Unmarshal(stdout.Bytes(), &response); err != nil { + t.Fatalf("failed to decode stdout: %v\n%s", err, stdout.String()) + } + if !response.IsPlaying || response.IsPaused { + t.Fatalf("response state mismatch: %#v", response) + } + if !response.ResumedFromPause { + t.Fatalf("ResumedFromPause should be preserved from the initial Resume response: %#v", response) + } + + firstRequest := readControlPlayModeRequest(t, requests) + if firstRequest[controlPlayModeActionParam] != "Resume" { + t.Fatalf("Unity-bound initial request must keep Resume: %#v", firstRequest) + } + secondRequest := readControlPlayModeRequest(t, requests) + if secondRequest[controlPlayModeStatusOnlyParam] != true { + t.Fatalf("status request mismatch: %#v", secondRequest) + } + + select { + case err := <-serverErr: + t.Fatalf("server failed: %v", err) + default: + } +} + // Verifies that live Unity tool caches using number schemas still drive integer wait budgets. func TestControlPlayModeTimeoutSecondsAcceptsFloatSchemaValue(t *testing.T) { params := map[string]any{controlPlayModeTimeoutParam: 12.0}