diff --git a/.agents/skills/uloop-execute-dynamic-code/SKILL.md b/.agents/skills/uloop-execute-dynamic-code/SKILL.md index 5df3425a5..8161911c3 100644 --- a/.agents/skills/uloop-execute-dynamic-code/SKILL.md +++ b/.agents/skills/uloop-execute-dynamic-code/SKILL.md @@ -1,5 +1,6 @@ --- name: uloop-execute-dynamic-code +toolName: execute-dynamic-code description: "Execute C# code dynamically in Unity Editor. Use find-game-objects first for basic selected GameObject discovery or property inspection. Use when you need to: (1) Wire prefab/material references and AddComponent operations, (2) Edit SerializedObject properties and reference wiring, (3) Perform scene/hierarchy edits and batch operations, (4) Execute Unity Editor menu commands through EditorApplication.ExecuteMenuItem, (5) PlayMode automation (click buttons, invoke methods, tweak runtime state), (6) PlayMode UI controls (InputField, Slider, Toggle, Dropdown), (7) PlayMode inspection that requires custom Unity API code beyond existing tools. NOT for file I/O or script authoring." context: fork --- @@ -23,7 +24,7 @@ For basic selected GameObject discovery or property inspection, use `find-game-o - `--code ''` (required): Inline C# statements to execute. Use direct statements only; `return` is optional, and `using` directives may appear at the top of the snippet. - **Shell quoting**: bash/zsh uses single quotes, for example `uloop execute-dynamic-code --code 'using UnityEngine; return Mathf.PI;'`. PowerShell doubles inner quotes (`'Debug.Log(""Hello!"");'`). - `--parameters {}` (advanced, optional): Pass an object when reusing a snippet with varying data or when keeping values outside the code. Values are exposed as `parameters["param0"]`, `parameters["param1"]`, and so on. Omit this flag for most snippets, and pass an object instead of a JSON string. -- `--compile-only` (optional): Compile the snippet without executing it. Use this when you want Roslyn diagnostics before running new code. +- `--no-wait-for-domain-reload` (optional): Return without waiting for Domain Reload recovery. Omit this for normal editor mutation workflows. ## Code Rules diff --git a/.claude/skills/uloop-execute-dynamic-code/SKILL.md b/.claude/skills/uloop-execute-dynamic-code/SKILL.md index 5df3425a5..8161911c3 100644 --- a/.claude/skills/uloop-execute-dynamic-code/SKILL.md +++ b/.claude/skills/uloop-execute-dynamic-code/SKILL.md @@ -1,5 +1,6 @@ --- name: uloop-execute-dynamic-code +toolName: execute-dynamic-code description: "Execute C# code dynamically in Unity Editor. Use find-game-objects first for basic selected GameObject discovery or property inspection. Use when you need to: (1) Wire prefab/material references and AddComponent operations, (2) Edit SerializedObject properties and reference wiring, (3) Perform scene/hierarchy edits and batch operations, (4) Execute Unity Editor menu commands through EditorApplication.ExecuteMenuItem, (5) PlayMode automation (click buttons, invoke methods, tweak runtime state), (6) PlayMode UI controls (InputField, Slider, Toggle, Dropdown), (7) PlayMode inspection that requires custom Unity API code beyond existing tools. NOT for file I/O or script authoring." context: fork --- @@ -23,7 +24,7 @@ For basic selected GameObject discovery or property inspection, use `find-game-o - `--code ''` (required): Inline C# statements to execute. Use direct statements only; `return` is optional, and `using` directives may appear at the top of the snippet. - **Shell quoting**: bash/zsh uses single quotes, for example `uloop execute-dynamic-code --code 'using UnityEngine; return Mathf.PI;'`. PowerShell doubles inner quotes (`'Debug.Log(""Hello!"");'`). - `--parameters {}` (advanced, optional): Pass an object when reusing a snippet with varying data or when keeping values outside the code. Values are exposed as `parameters["param0"]`, `parameters["param1"]`, and so on. Omit this flag for most snippets, and pass an object instead of a JSON string. -- `--compile-only` (optional): Compile the snippet without executing it. Use this when you want Roslyn diagnostics before running new code. +- `--no-wait-for-domain-reload` (optional): Return without waiting for Domain Reload recovery. Omit this for normal editor mutation workflows. ## Code Rules diff --git a/Assets/Tests/Editor/CliSetupApplicationServiceTests.cs b/Assets/Tests/Editor/CliSetupApplicationServiceTests.cs index 19f605e68..fb8618a19 100644 --- a/Assets/Tests/Editor/CliSetupApplicationServiceTests.cs +++ b/Assets/Tests/Editor/CliSetupApplicationServiceTests.cs @@ -27,18 +27,29 @@ public async Task InstallGlobalCliAsync_UsesMinimumRequiredCliReleaseTag() Assert.That( nativeCliInstaller.InstalledVersion, - Is.EqualTo(CliConstants.CLI_RELEASE_TAG_PREFIX + CliConstants.MINIMUM_REQUIRED_CLI_VERSION)); + Is.EqualTo(CliConstants.MINIMUM_REQUIRED_CLI_RELEASE_TAG)); } [Test] - public void GetMinimumRequiredCliVersion_RequiresTerminalUninstallCliRelease() + public void GetMinimumRequiredCliVersion_RequiresDynamicCodeDomainReloadWaitCliRelease() { - // Verifies this package release rejects CLIs older than the terminal uninstall command. + // Verifies this package release rejects CLIs without dynamic-code domain reload waiting. CliSetupApplicationService service = new( new FakeCliInstallationDetector(new string[] { null }), new FakeNativeCliInstaller()); - Assert.That(service.GetMinimumRequiredCliVersion(), Is.EqualTo("3.0.0-beta.8")); + Assert.That(service.GetMinimumRequiredCliVersion(), Is.EqualTo("3.0.0-beta.9")); + } + + [Test] + public void GetMinimumRequiredCliReleaseTag_UsesCliGitHubReleaseTag() + { + // Verifies installers target the prefixed CLI GitHub Release tag. + CliSetupApplicationService service = new( + new FakeCliInstallationDetector(new string[] { null }), + new FakeNativeCliInstaller()); + + Assert.That(service.GetMinimumRequiredCliReleaseTag(), Is.EqualTo("cli-v3.0.0-beta.9")); } [Test] @@ -56,7 +67,7 @@ public void GetGlobalCliInstallCommand_UsesMinimumRequiredCliReleaseTag() Assert.That( command.ManualCommand, - Is.EqualTo("install " + CliConstants.CLI_RELEASE_TAG_PREFIX + CliConstants.MINIMUM_REQUIRED_CLI_VERSION)); + Is.EqualTo("install " + CliConstants.MINIMUM_REQUIRED_CLI_RELEASE_TAG)); } private sealed class FakeCliInstallationDetector : ICliInstallationDetector diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/ExecuteDynamicCodeUseCaseTests.cs b/Assets/Tests/Editor/DynamicCodeToolTests/ExecuteDynamicCodeUseCaseTests.cs index 89ef62239..d7aaff5be 100644 --- a/Assets/Tests/Editor/DynamicCodeToolTests/ExecuteDynamicCodeUseCaseTests.cs +++ b/Assets/Tests/Editor/DynamicCodeToolTests/ExecuteDynamicCodeUseCaseTests.cs @@ -42,6 +42,53 @@ public void ExecuteDynamicCodeResponse_WhenSerializedWithoutTimings_DoesNotExpos Assert.That(serializedResponse["Timings"], Is.Null); Assert.That(serializedResponse["EmitTimingsInJsonResponse"], Is.Null); Assert.That(serializedResponse["EmitsTimingsInJsonResponse"], Is.Null); + Assert.That(serializedResponse["DomainReloadWaitRequired"], Is.Null); + } + + [Test] + public void DynamicCodeDomainReloadWaitSignal_WhenEditorReportsReloadWork_ShouldRequestWait() + { + // Tests that native CLI waits are driven by explicit Unity reload signals. + ExecuteDynamicCodeSchema schema = new() + { + WaitForDomainReload = true, + CompileOnly = false + }; + + bool shouldWait = DynamicCodeDomainReloadWaitSignal.ShouldRequestWait( + schema, + editorIsCompiling: true, + reloadSignalObserved: false); + + Assert.That(shouldWait, Is.True); + } + + [Test] + public void DynamicCodeDomainReloadWaitSignal_WhenRequestCannotWait_ShouldNotRequestWait() + { + // Tests that compile-only and explicit no-wait requests keep their fast paths. + ExecuteDynamicCodeSchema compileOnlySchema = new() + { + WaitForDomainReload = true, + CompileOnly = true + }; + ExecuteDynamicCodeSchema noWaitSchema = new() + { + WaitForDomainReload = false, + CompileOnly = false + }; + + bool compileOnlyShouldWait = DynamicCodeDomainReloadWaitSignal.ShouldRequestWait( + compileOnlySchema, + editorIsCompiling: true, + reloadSignalObserved: true); + bool noWaitShouldWait = DynamicCodeDomainReloadWaitSignal.ShouldRequestWait( + noWaitSchema, + editorIsCompiling: true, + reloadSignalObserved: true); + + Assert.That(compileOnlyShouldWait, Is.False); + Assert.That(noWaitShouldWait, Is.False); } [Test] diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/FirstPartyToolSchemaMetadataTests.cs b/Assets/Tests/Editor/DynamicCodeToolTests/FirstPartyToolSchemaMetadataTests.cs index 679e91bfe..ca6531f0e 100644 --- a/Assets/Tests/Editor/DynamicCodeToolTests/FirstPartyToolSchemaMetadataTests.cs +++ b/Assets/Tests/Editor/DynamicCodeToolTests/FirstPartyToolSchemaMetadataTests.cs @@ -4,6 +4,7 @@ using NUnit.Framework; using UnityEditor; +using io.github.hatayama.UnityCliLoop.FirstPartyTools; using io.github.hatayama.UnityCliLoop.ToolContracts; using ComponentModelDescriptionAttribute = System.ComponentModel.DescriptionAttribute; @@ -43,5 +44,14 @@ public void FirstPartySchemaProperties_WhenLoaded_ShouldNotExposeDescriptionAttr } } } + + [Test] + public void ExecuteDynamicCodeSchema_WhenCreated_ShouldWaitForDomainReloadByDefault() + { + // Tests that execute-dynamic-code keeps CLI calls on the safe post-reload path by default. + ExecuteDynamicCodeSchema schema = new(); + + Assert.That(schema.WaitForDomainReload, Is.True); + } } } diff --git a/Packages/src/Cli~/contract.json b/Packages/src/Cli~/contract.json index cf1df11b7..0cda7081e 100644 --- a/Packages/src/Cli~/contract.json +++ b/Packages/src/Cli~/contract.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "cliVersion": "3.0.0-beta.8" + "cliVersion": "3.0.0-beta.9" } diff --git a/Packages/src/Cli~/dist/darwin-amd64/uloop b/Packages/src/Cli~/dist/darwin-amd64/uloop index da4e7549d..9eb3359f3 100755 Binary files a/Packages/src/Cli~/dist/darwin-amd64/uloop and b/Packages/src/Cli~/dist/darwin-amd64/uloop differ diff --git a/Packages/src/Cli~/dist/darwin-arm64/uloop b/Packages/src/Cli~/dist/darwin-arm64/uloop index 7a3bc7774..3d2a05b9e 100755 Binary files a/Packages/src/Cli~/dist/darwin-arm64/uloop and b/Packages/src/Cli~/dist/darwin-arm64/uloop differ diff --git a/Packages/src/Cli~/dist/windows-amd64/uloop.exe b/Packages/src/Cli~/dist/windows-amd64/uloop.exe index 54e77d85f..da0d06960 100755 Binary files a/Packages/src/Cli~/dist/windows-amd64/uloop.exe and b/Packages/src/Cli~/dist/windows-amd64/uloop.exe differ diff --git a/Packages/src/Cli~/internal/cli/compile_wait.go b/Packages/src/Cli~/internal/cli/compile_wait.go index 33e437b11..2b3cba223 100644 --- a/Packages/src/Cli~/internal/cli/compile_wait.go +++ b/Packages/src/Cli~/internal/cli/compile_wait.go @@ -18,7 +18,7 @@ import ( const ( compileCommandName = "compile" compileRequestIDParam = "RequestId" - compileWaitParam = "WaitForDomainReload" + compileWaitParam = domainReloadWaitParam compileResultRelativeDir = "Temp/UnityCliLoop/compile-results" compileWaitTimeout = 90 * time.Second compileWaitPollInterval = 50 * time.Millisecond @@ -37,18 +37,7 @@ func shouldWaitForCompileDomainReload(command string, params map[string]any) boo if command != compileCommandName { return false } - return compileDomainReloadWaitEnabled(params) -} - -func compileDomainReloadWaitEnabled(params map[string]any) bool { - value, ok := params[compileWaitParam].(bool) - if ok { - return value - } - - // Why: native CLI compile is a user-facing checkpoint; waiting by default - // ensures the post-compile readiness probe runs after the domain is usable. - return true + return domainReloadWaitEnabled(params) } func prepareCompileWaitParams(params map[string]any) (string, error) { diff --git a/Packages/src/Cli~/internal/cli/compile_wait_test.go b/Packages/src/Cli~/internal/cli/compile_wait_test.go index 67e3ee79b..97867f7ef 100644 --- a/Packages/src/Cli~/internal/cli/compile_wait_test.go +++ b/Packages/src/Cli~/internal/cli/compile_wait_test.go @@ -65,6 +65,70 @@ func TestShouldWaitForCompileDomainReloadRespectsExplicitFalse(t *testing.T) { } } +// Verifies that execute-dynamic-code waits for domain reload by default. +func TestShouldWaitForExecuteDynamicCodeDomainReloadDefaultsToExecuteDynamicCode(t *testing.T) { + if !shouldWaitForExecuteDynamicCodeDomainReload(executeDynamicCodeCommandName, map[string]any{}) { + t.Fatal("execute-dynamic-code should wait for domain reload by default") + } + + if shouldWaitForExecuteDynamicCodeDomainReload("get-logs", map[string]any{}) { + t.Fatal("non-execute-dynamic-code commands should not use dynamic-code wait") + } +} + +// Verifies that execute-dynamic-code can preserve the fast no-wait path. +func TestShouldWaitForExecuteDynamicCodeDomainReloadRespectsExplicitFalse(t *testing.T) { + params := map[string]any{compileWaitParam: false} + + if shouldWaitForExecuteDynamicCodeDomainReload(executeDynamicCodeCommandName, params) { + t.Fatal("execute-dynamic-code wait should be disabled by an explicit false flag") + } +} + +// Verifies that compile-only dynamic-code requests keep the diagnostic path fast. +func TestShouldWaitForExecuteDynamicCodeDomainReloadSkipsCompileOnly(t *testing.T) { + params := map[string]any{"CompileOnly": true} + + if shouldWaitForExecuteDynamicCodeDomainReload(executeDynamicCodeCommandName, params) { + t.Fatal("compile-only execute-dynamic-code should not wait for domain reload") + } +} + +// Verifies that execute-dynamic-code waits only when Unity explicitly reports a reload signal. +func TestExecuteDynamicCodeDomainReloadWaitRequiredReadsResponseSignal(t *testing.T) { + result := []byte(`{"Success":true,"DomainReloadWaitRequired":true}`) + + if !executeDynamicCodeDomainReloadWaitRequired(result) { + t.Fatal("dynamic-code response should request a reload wait") + } + + if executeDynamicCodeDomainReloadWaitRequired([]byte(`{"Success":true}`)) { + t.Fatal("dynamic-code response without a reload signal should not request a wait") + } +} + +// Verifies that dispatched dynamic-code disconnects still wait for reload recovery. +func TestShouldWaitForExecuteDynamicCodeDisconnectWaitsAfterDispatchedTransportLoss(t *testing.T) { + outcome := unityipc.UnitySendOutcome{RequestDispatched: true} + + if !shouldWaitForExecuteDynamicCodeDisconnect(fmt.Errorf("EOF"), outcome) { + t.Fatal("dispatched transport loss should wait for domain reload recovery") + } + + if shouldWaitForExecuteDynamicCodeDisconnect(fmt.Errorf("EOF"), unityipc.UnitySendOutcome{}) { + t.Fatal("undispatched transport loss should not use dynamic-code reload wait") + } +} + +// Verifies that the CLI does not expose its internal reload-wait response field to users. +func TestStripExecuteDynamicCodeControlResultRemovesReloadSignal(t *testing.T) { + result := stripExecuteDynamicCodeControlResult([]byte(`{"Success":true,"DomainReloadWaitRequired":true}`)) + + if strings.Contains(string(result), "DomainReloadWaitRequired") { + t.Fatalf("control field leaked into user output: %s", result) + } +} + // Verifies that compile wait preparation creates a request id and enables reload waiting. func TestPrepareCompileWaitParamsForcesDomainReloadWait(t *testing.T) { params := map[string]any{} diff --git a/Packages/src/Cli~/internal/cli/completion.go b/Packages/src/Cli~/internal/cli/completion.go index 6f87b7ac4..df05ca854 100644 --- a/Packages/src/Cli~/internal/cli/completion.go +++ b/Packages/src/Cli~/internal/cli/completion.go @@ -246,19 +246,23 @@ func printOptionsForCommand(command string, cache toolsCache, stdout io.Writer) writeLine(stdout, "") return } + if command == executeDynamicCodeCommandName { + if tool, ok := findTool(loadDefaultTools(), command); ok { + printOptionsForTool(tool, stdout) + } + return + } tool, ok := findTool(cache, command) if !ok { return } - schema := tool.EffectiveInputSchema() - options := make([]string, 0, len(schema.Properties)) - for propertyName, property := range schema.Properties { - options = append(options, "--"+optionNameForProperty(propertyName, property)) - } - sort.Strings(options) - writeLine(stdout, strings.Join(options, "\n")) + printOptionsForTool(tool, stdout) +} + +func printOptionsForTool(tool toolDefinition, stdout io.Writer) { + writeLine(stdout, strings.Join(visibleOptionNamesForTool(tool), "\n")) } func detectShell() string { diff --git a/Packages/src/Cli~/internal/cli/completion_test.go b/Packages/src/Cli~/internal/cli/completion_test.go index 2c8eca815..25e5f0c12 100644 --- a/Packages/src/Cli~/internal/cli/completion_test.go +++ b/Packages/src/Cli~/internal/cli/completion_test.go @@ -57,6 +57,71 @@ func TestCompletionListOptionsUsesToolSchema(t *testing.T) { } } +func TestCompletionListOptionsUsesExecuteDynamicCodeNoWaitFlag(t *testing.T) { + // Verifies shell completion exposes the default-on reload wait as a negated flag. + var stdout bytes.Buffer + handled, code := tryHandleCompletionRequest( + []string{"--list-options", executeDynamicCodeCommandName}, + loadDefaultTools(), + &stdout, + &bytes.Buffer{}, + ) + + if !handled { + t.Fatal("completion request was not handled") + } + if code != 0 { + t.Fatalf("exit code mismatch: %d", code) + } + + output := stdout.String() + if !strings.Contains(output, "--no-wait-for-domain-reload") { + t.Fatalf("execute-dynamic-code no-wait option was not listed: %s", output) + } + if strings.Contains(output, "--wait-for-domain-reload") { + t.Fatalf("execute-dynamic-code wait option should be negated only: %s", output) + } + if strings.Contains(output, "--compile-only") { + t.Fatalf("execute-dynamic-code internal compile-only option should stay hidden: %s", output) + } +} + +func TestCompletionListOptionsUsesEmbeddedExecuteDynamicCodeDefinition(t *testing.T) { + // Verifies stale project caches do not hide hot-path execute-dynamic-code options. + var stdout bytes.Buffer + cache := toolsCache{ + Tools: []toolDefinition{ + { + Name: "execute-dynamic-code", + InputSchema: inputSchema{ + Properties: map[string]toolProperty{ + "Code": {Type: "string"}, + }, + }, + }, + }, + } + + handled, code := tryHandleCompletionRequest( + []string{"--list-options", executeDynamicCodeCommandName}, + cache, + &stdout, + &bytes.Buffer{}, + ) + + if !handled { + t.Fatal("completion request was not handled") + } + if code != 0 { + t.Fatalf("exit code mismatch: %d", code) + } + + output := stdout.String() + if !strings.Contains(output, "--no-wait-for-domain-reload") { + t.Fatalf("embedded execute-dynamic-code options were not used: %s", output) + } +} + func TestCompletionListOptionsUsesNativeLaunchOptions(t *testing.T) { // Verifies shell completion still suggests native launch flags after CLI unification. var stdout bytes.Buffer diff --git a/Packages/src/Cli~/internal/cli/dynamic_code_wait.go b/Packages/src/Cli~/internal/cli/dynamic_code_wait.go new file mode 100644 index 000000000..18b70cf8a --- /dev/null +++ b/Packages/src/Cli~/internal/cli/dynamic_code_wait.go @@ -0,0 +1,68 @@ +package cli + +import ( + "encoding/json" + + "github.com/hatayama/unity-cli-loop/Packages/src/Cli/internal/unityipc" +) + +const ( + domainReloadWaitParam = "WaitForDomainReload" + dynamicCodeCompileOnlyParam = "CompileOnly" + dynamicCodeDomainReloadWaitRequiredField = "DomainReloadWaitRequired" +) + +func shouldWaitForExecuteDynamicCodeDomainReload(command string, params map[string]any) bool { + if command != executeDynamicCodeCommandName { + return false + } + if compileOnly, ok := params[dynamicCodeCompileOnlyParam].(bool); ok && compileOnly { + return false + } + return domainReloadWaitEnabled(params) +} + +func domainReloadWaitEnabled(params map[string]any) bool { + value, ok := params[domainReloadWaitParam].(bool) + if ok { + return value + } + + // Why: user-facing Unity mutation commands are checkpoints; returning only + // after reload recovery avoids handing the next tool a half-reset editor. + return true +} + +func executeDynamicCodeDomainReloadWaitRequired(result []byte) bool { + var payload struct { + DomainReloadWaitRequired bool `json:"DomainReloadWaitRequired"` + } + if err := json.Unmarshal(result, &payload); err != nil { + return false + } + return payload.DomainReloadWaitRequired +} + +func shouldWaitForExecuteDynamicCodeDisconnect(err error, outcome unityipc.UnitySendOutcome) bool { + if err == nil { + return false + } + if !outcome.RequestDispatched { + return false + } + return isTransportDisconnectError(err) +} + +func stripExecuteDynamicCodeControlResult(result []byte) []byte { + var payload map[string]any + if err := json.Unmarshal(result, &payload); err != nil { + return result + } + + delete(payload, dynamicCodeDomainReloadWaitRequiredField) + sanitized, err := json.Marshal(payload) + if err != nil { + return result + } + return sanitized +} diff --git a/Packages/src/Cli~/internal/cli/launch_test.go b/Packages/src/Cli~/internal/cli/launch_test.go index 3a207f45f..82650b12f 100644 --- a/Packages/src/Cli~/internal/cli/launch_test.go +++ b/Packages/src/Cli~/internal/cli/launch_test.go @@ -118,6 +118,9 @@ func TestExecuteDynamicCodeReadinessProbeParamsUseForegroundWarmup(t *testing.T) if params["YieldToForegroundRequests"] != false { t.Fatalf("readiness probe should use foreground warmup: %#v", params["YieldToForegroundRequests"]) } + if params[domainReloadWaitParam] != false { + t.Fatalf("readiness probe should not wait for its own reload check: %#v", params[domainReloadWaitParam]) + } } func TestNewUnityLaunchCommandIsNotContextCancelable(t *testing.T) { diff --git a/Packages/src/Cli~/internal/cli/run.go b/Packages/src/Cli~/internal/cli/run.go index 39d98e797..e18431c1b 100644 --- a/Packages/src/Cli~/internal/cli/run.go +++ b/Packages/src/Cli~/internal/cli/run.go @@ -127,6 +127,9 @@ func runTool(ctx context.Context, connection unityipc.Connection, command string if shouldWaitForCompileDomainReload(command, params) { return runCompileWithDomainReloadWait(ctx, connection, params, stdout, stderr) } + if shouldWaitForExecuteDynamicCodeDomainReload(command, params) { + return runExecuteDynamicCodeWithDomainReloadWait(ctx, connection, params, stdout, stderr) + } applyDebugTimingParams(command, params) startedAt := time.Now() @@ -154,6 +157,59 @@ func runTool(ctx context.Context, connection unityipc.Connection, command string return 0 } +func runExecuteDynamicCodeWithDomainReloadWait(ctx context.Context, connection unityipc.Connection, params map[string]any, stdout io.Writer, stderr io.Writer) int { + applyDebugTimingParams(executeDynamicCodeCommandName, params) + startedAt := time.Now() + spinner := newToolSpinner(stderr, executeDynamicCodeCommandName) + client := unityipc.NewClient(connection, version) + outcome, err := client.SendWithProgressOutcome( + ctx, + executeDynamicCodeCommandName, + params, + func(string) { + spinner.Update("Executing execute-dynamic-code...") + }, + ) + if err != nil { + if shouldWaitForExecuteDynamicCodeDisconnect(err, outcome) { + spinner.Update("Connection lost during execute-dynamic-code. Waiting for domain reload to complete...") + if waitErr := waitForToolReadiness(ctx, connection.ProjectRoot); waitErr != nil { + spinner.Stop() + writeClassifiedError(stderr, waitErr, errorContext{ + projectRoot: connection.ProjectRoot, + command: executeDynamicCodeCommandName, + }) + return 1 + } + } + spinner.Stop() + writeDebugTiming(stderr, executeDynamicCodeCommandName, time.Since(startedAt), outcome) + writeToolFailure(stderr, err, outcome, errorContext{ + projectRoot: connection.ProjectRoot, + command: executeDynamicCodeCommandName, + }) + return 1 + } + + if executeDynamicCodeDomainReloadWaitRequired(outcome.Result) { + spinner.Update("Waiting for domain reload to complete...") + if err := waitForToolReadiness(ctx, connection.ProjectRoot); err != nil { + spinner.Stop() + writeClassifiedError(stderr, err, errorContext{ + projectRoot: connection.ProjectRoot, + command: executeDynamicCodeCommandName, + }) + return 1 + } + } + + spinner.Stop() + result := stripExecuteDynamicCodeControlResult(outcome.Result) + writeJSON(stdout, stripDebugTimingResult(executeDynamicCodeCommandName, result)) + writeDebugTiming(stderr, executeDynamicCodeCommandName, time.Since(startedAt), outcome) + return 0 +} + func runCompileWithDomainReloadWait(ctx context.Context, connection unityipc.Connection, params map[string]any, stdout io.Writer, stderr io.Writer) int { requestID, err := prepareCompileWaitParams(params) if err != nil { diff --git a/Packages/src/Cli~/internal/cli/tool_readiness.go b/Packages/src/Cli~/internal/cli/tool_readiness.go index eb4e986c4..6b4ac7c26 100644 --- a/Packages/src/Cli~/internal/cli/tool_readiness.go +++ b/Packages/src/Cli~/internal/cli/tool_readiness.go @@ -144,6 +144,7 @@ func executeDynamicCodeReadinessProbeParams() map[string]any { return map[string]any{ "Code": executeDynamicCodeReadinessProbe, "CompileOnly": false, + domainReloadWaitParam: false, "YieldToForegroundRequests": false, } } diff --git a/Packages/src/Cli~/internal/cli/tools.go b/Packages/src/Cli~/internal/cli/tools.go index 77b212278..d64cc6d88 100644 --- a/Packages/src/Cli~/internal/cli/tools.go +++ b/Packages/src/Cli~/internal/cli/tools.go @@ -2,6 +2,7 @@ package cli import ( "encoding/json" + "sort" "strconv" "strings" @@ -296,6 +297,19 @@ func optionNameForProperty(propertyName string, property toolProperty) string { return kebabName } +func visibleOptionNamesForTool(tool toolDefinition) []string { + schema := tool.EffectiveInputSchema() + options := make([]string, 0, len(schema.Properties)) + for propertyName, property := range schema.Properties { + if property.Hidden { + continue + } + options = append(options, "--"+optionNameForProperty(propertyName, property)) + } + sort.Strings(options) + return options +} + func isBooleanProperty(property toolProperty) bool { return strings.EqualFold(property.Type, "boolean") } diff --git a/Packages/src/Cli~/internal/cli/tools_test.go b/Packages/src/Cli~/internal/cli/tools_test.go index 636baf75f..90558f77b 100644 --- a/Packages/src/Cli~/internal/cli/tools_test.go +++ b/Packages/src/Cli~/internal/cli/tools_test.go @@ -68,6 +68,40 @@ func TestBuildToolParamsConvertsDefaultTrueBooleanToNegatedFlag(t *testing.T) { } } +// Tests that execute-dynamic-code accepts --no-wait-for-domain-reload from embedded tools. +func TestBuildToolParamsConvertsExecuteDynamicCodeNoWaitFlag(t *testing.T) { + tool, ok := findTool(loadDefaultTools(), executeDynamicCodeCommandName) + if !ok { + t.Fatal("execute-dynamic-code was not found in default tools") + } + + params, _, err := buildToolParams([]string{"--no-wait-for-domain-reload"}, tool) + if err != nil { + t.Fatalf("buildToolParams failed: %v", err) + } + + if params[compileWaitParam] != false { + t.Fatalf("WaitForDomainReload mismatch: %#v", params[compileWaitParam]) + } +} + +// Tests that hidden execute-dynamic-code options remain available for internal callers. +func TestBuildToolParamsAcceptsHiddenExecuteDynamicCodeCompileOnlyFlag(t *testing.T) { + tool, ok := findTool(loadDefaultTools(), executeDynamicCodeCommandName) + if !ok { + t.Fatal("execute-dynamic-code was not found in default tools") + } + + params, _, err := buildToolParams([]string{"--compile-only"}, tool) + if err != nil { + t.Fatalf("buildToolParams failed: %v", err) + } + + if params[dynamicCodeCompileOnlyParam] != true { + t.Fatalf("CompileOnly mismatch: %#v", params[dynamicCodeCompileOnlyParam]) + } +} + // Tests that boolean tool arguments reject the old explicit true/false value form. func TestBuildToolParamsRejectsExplicitBooleanValues(t *testing.T) { tool := toolDefinition{ diff --git a/Packages/src/Cli~/internal/tools/default-tools.json b/Packages/src/Cli~/internal/tools/default-tools.json index d0ca68401..5c835cef7 100644 --- a/Packages/src/Cli~/internal/tools/default-tools.json +++ b/Packages/src/Cli~/internal/tools/default-tools.json @@ -1,5 +1,5 @@ { - "version": "3.0.0-beta.8", + "version": "3.0.0-beta.9", "tools": [ { "name": "compile", @@ -284,7 +284,13 @@ }, "CompileOnly": { "type": "boolean", - "description": "Compile only without execution" + "description": "Compile only without execution", + "hidden": true + }, + "WaitForDomainReload": { + "type": "boolean", + "description": "Wait for domain reload completion before returning", + "default": true }, "YieldToForegroundRequests": { "type": "boolean", diff --git a/Packages/src/Cli~/internal/tools/types.go b/Packages/src/Cli~/internal/tools/types.go index c6e6a0256..2a6b83f2b 100644 --- a/Packages/src/Cli~/internal/tools/types.go +++ b/Packages/src/Cli~/internal/tools/types.go @@ -25,6 +25,7 @@ type ToolProperty struct { 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"` diff --git a/Packages/src/Editor/Application/CliSetupApplicationService.cs b/Packages/src/Editor/Application/CliSetupApplicationService.cs index 112e56bd3..3b9ec4e2a 100644 --- a/Packages/src/Editor/Application/CliSetupApplicationService.cs +++ b/Packages/src/Editor/Application/CliSetupApplicationService.cs @@ -115,7 +115,7 @@ public string GetMinimumRequiredCliVersion() public string GetMinimumRequiredCliReleaseTag() { - return CliConstants.CLI_RELEASE_TAG_PREFIX + GetMinimumRequiredCliVersion(); + return CliConstants.MINIMUM_REQUIRED_CLI_RELEASE_TAG; } public bool IsPackageOwnedCurrentUserInstallPath( diff --git a/Packages/src/Editor/Domain/CliConstants.cs b/Packages/src/Editor/Domain/CliConstants.cs index c98c9e55e..c09e6a96f 100644 --- a/Packages/src/Editor/Domain/CliConstants.cs +++ b/Packages/src/Editor/Domain/CliConstants.cs @@ -6,7 +6,8 @@ namespace io.github.hatayama.UnityCliLoop.Domain public static class CliConstants { public const string EXECUTABLE_NAME = "uloop"; - public const string MINIMUM_REQUIRED_CLI_VERSION = "3.0.0-beta.8"; + public const string MINIMUM_REQUIRED_CLI_VERSION = "3.0.0-beta.9"; + public const string MINIMUM_REQUIRED_CLI_RELEASE_TAG = CLI_RELEASE_TAG_PREFIX + MINIMUM_REQUIRED_CLI_VERSION; public const string VERSION_FLAG = "--version"; public const string SHORT_VERSION_FLAG = "-v"; public const string RAW_CONTENT_BASE_URL = "https://raw.githubusercontent.com/hatayama/unity-cli-loop"; diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCodeDomainReloadWaitSignal.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCodeDomainReloadWaitSignal.cs new file mode 100644 index 000000000..ae999ea01 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCodeDomainReloadWaitSignal.cs @@ -0,0 +1,107 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using UnityEditor; +using UnityEditor.Compilation; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Observes editor compile and reload signals while dynamic user code is running. + /// + internal sealed class DynamicCodeDomainReloadWaitSignal : IDisposable + { + private const int SIGNAL_SETTLE_FRAMES = 2; + + private readonly ExecuteDynamicCodeSchema _parameters; + private readonly bool _isObserving; + + private bool _reloadSignalObserved; + + private DynamicCodeDomainReloadWaitSignal(ExecuteDynamicCodeSchema parameters) + { + _parameters = parameters; + _isObserving = ShouldObserve(parameters); + if (!_isObserving) + { + return; + } + + CompilationPipeline.compilationStarted += OnCompilationStarted; + AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; + } + + public static DynamicCodeDomainReloadWaitSignal Start(ExecuteDynamicCodeSchema parameters) + { + return new DynamicCodeDomainReloadWaitSignal(parameters); + } + + public async Task ShouldWaitAsync(CancellationToken ct) + { + if (!_isObserving) + { + return false; + } + + if (ShouldRequestWait(_parameters, EditorApplication.isCompiling, _reloadSignalObserved)) + { + return true; + } + + for (int frame = 0; frame < SIGNAL_SETTLE_FRAMES; frame++) + { + await EditorDelay.DelayFrame(1, ct); + if (ShouldRequestWait(_parameters, EditorApplication.isCompiling, _reloadSignalObserved)) + { + return true; + } + } + + return false; + } + + internal static bool ShouldRequestWait( + ExecuteDynamicCodeSchema parameters, + bool editorIsCompiling, + bool reloadSignalObserved) + { + if (!ShouldObserve(parameters)) + { + return false; + } + + return editorIsCompiling || reloadSignalObserved; + } + + public void Dispose() + { + if (!_isObserving) + { + return; + } + + CompilationPipeline.compilationStarted -= OnCompilationStarted; + AssemblyReloadEvents.beforeAssemblyReload -= OnBeforeAssemblyReload; + } + + private static bool ShouldObserve(ExecuteDynamicCodeSchema parameters) + { + return parameters != null + && parameters.WaitForDomainReload + && !parameters.CompileOnly; + } + + private void OnCompilationStarted(object context) + { + _reloadSignalObserved = true; + } + + private void OnBeforeAssemblyReload() + { + _reloadSignalObserved = true; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCodeDomainReloadWaitSignal.cs.meta b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCodeDomainReloadWaitSignal.cs.meta new file mode 100644 index 000000000..10a00a5dc --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCodeDomainReloadWaitSignal.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 49c7b48f1f2944823bd1410ceab183c4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeResponse.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeResponse.cs index 775e6967c..a41c91d2f 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeResponse.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeResponse.cs @@ -53,6 +53,11 @@ public string Error /// public List Diagnostics { get; set; } = new(); + /// + /// Why: the native CLI needs an explicit Unity-side reload signal before it can safely wait. + /// + public bool DomainReloadWaitRequired { get; set; } = false; + /// /// Lightweight internal timings for benchmark comparison. /// @@ -83,6 +88,11 @@ public bool ShouldSerializeEmitTimingsInJsonResponse() { return false; } + + public bool ShouldSerializeDomainReloadWaitRequired() + { + return DomainReloadWaitRequired; + } } /// diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeSchema.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeSchema.cs index 7e23ed9b4..791e8decd 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeSchema.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeSchema.cs @@ -20,6 +20,8 @@ public class ExecuteDynamicCodeSchema : UnityCliLoopToolSchema /// Compile only (do not execute) public bool CompileOnly { get; set; } = false; + public bool WaitForDomainReload { get; set; } = true; + public bool YieldToForegroundRequests { get; set; } = false; [Browsable(false)] diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeUseCase.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeUseCase.cs index f0c983878..c6bed3ada 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeUseCase.cs @@ -31,6 +31,7 @@ public async Task ExecuteAsync( { string correlationId = UnityCliLoopConstants.GenerateCorrelationId(); DynamicCodeSecurityLevel editorLevel = DynamicCodeSecurityLevel.Restricted; + DynamicCodeDomainReloadWaitSignal domainReloadWaitSignal = DynamicCodeDomainReloadWaitSignal.Start(parameters); try { @@ -79,6 +80,7 @@ public async Task ExecuteAsync( originalCode); response.SecurityLevel = editorLevel.ToString(); response.EmitTimingsInJsonResponse = parameters.IncludeTimings; + response.DomainReloadWaitRequired = await domainReloadWaitSignal.ShouldWaitAsync(cancellationToken); return response; } catch (OperationCanceledException) @@ -94,6 +96,10 @@ public async Task ExecuteAsync( response.EmitTimingsInJsonResponse = parameters?.IncludeTimings ?? false; return response; } + finally + { + domainReloadWaitSignal.Dispose(); + } } private static void LogExecutionStart( diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/SKILL.md index e18d8dabf..8161911c3 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/SKILL.md @@ -24,7 +24,7 @@ For basic selected GameObject discovery or property inspection, use `find-game-o - `--code ''` (required): Inline C# statements to execute. Use direct statements only; `return` is optional, and `using` directives may appear at the top of the snippet. - **Shell quoting**: bash/zsh uses single quotes, for example `uloop execute-dynamic-code --code 'using UnityEngine; return Mathf.PI;'`. PowerShell doubles inner quotes (`'Debug.Log(""Hello!"");'`). - `--parameters {}` (advanced, optional): Pass an object when reusing a snippet with varying data or when keeping values outside the code. Values are exposed as `parameters["param0"]`, `parameters["param1"]`, and so on. Omit this flag for most snippets, and pass an object instead of a JSON string. -- `--compile-only` (optional): Compile the snippet without executing it. Use this when you want Roslyn diagnostics before running new code. +- `--no-wait-for-domain-reload` (optional): Return without waiting for Domain Reload recovery. Omit this for normal editor mutation workflows. ## Code Rules diff --git a/docs/execute-dynamic-code-examples.md b/docs/execute-dynamic-code-examples.md index 3517ddea0..584348123 100644 --- a/docs/execute-dynamic-code-examples.md +++ b/docs/execute-dynamic-code-examples.md @@ -56,12 +56,6 @@ uloop execute-dynamic-code --code 'using UnityEngine; GameObject go = new GameOb uloop execute-dynamic-code --code 'int a = (int)parameters[0]; int b = (int)parameters[1]; return a * b;' --parameters '[6,7]' ``` -`compile-only` を試す。 - -```sh -uloop execute-dynamic-code --code 'using UnityEngine; Debug.Log("compile only sample"); return 123;' --compile-only -``` - ## Medium Examples Camera 情報を列挙する。