diff --git a/Assets/Tests/Editor/CompileResponseFactoryTests.cs b/Assets/Tests/Editor/CompileResponseFactoryTests.cs index 2b6b0f605..fde03ad9b 100644 --- a/Assets/Tests/Editor/CompileResponseFactoryTests.cs +++ b/Assets/Tests/Editor/CompileResponseFactoryTests.cs @@ -77,12 +77,14 @@ public void CreateResponse_WhenForceCompileIsUnknown_ExplainsNullDetails() CompileResponse response = CompileResponseFactory.CreateResponse(result, forceRecompile: true); - Assert.That(response.Success, Is.Null); + Assert.That(response.Success, Is.False); Assert.That(response.ErrorCount, Is.Null); Assert.That(response.WarningCount, Is.Null); Assert.That(response.Errors, Is.Null); Assert.That(response.Warnings, Is.Null); Assert.That(response.Message, Is.EqualTo(ForceCompileUnknownResult.MessageText)); + Assert.That(response.ErrorCode, Is.EqualTo(ForceCompileUnknownResult.ErrorCodeText)); + Assert.That(response.NextActions, Is.Not.Empty); } [Test] @@ -102,12 +104,14 @@ public void CreateResponse_WhenForceCompileHasOutcome_ExplainsNullDetails() CompileResponse response = CompileResponseFactory.CreateResponse(result, forceRecompile: true); - Assert.That(response.Success, Is.True); + Assert.That(response.Success, Is.False); Assert.That(response.ErrorCount, Is.Null); Assert.That(response.WarningCount, Is.Null); Assert.That(response.Errors, Is.Null); Assert.That(response.Warnings, Is.Null); Assert.That(response.Message, Is.EqualTo(ForceCompileUnknownResult.MessageText)); + Assert.That(response.ErrorCode, Is.EqualTo(ForceCompileUnknownResult.ErrorCodeText)); + Assert.That(response.NextActions, Is.Not.Empty); } [Test] @@ -142,7 +146,7 @@ public void CreateResponse_WhenIndeterminateNonForceCompileHasCounts_PreservesCo CompileResponse response = CompileResponseFactory.CreateResponse(result, forceRecompile: false); - Assert.That(response.Success, Is.Null); + Assert.That(response.Success, Is.False); Assert.That(response.ErrorCount, Is.EqualTo(1)); Assert.That(response.WarningCount, Is.EqualTo(1)); Assert.That(response.Errors, Is.Null); diff --git a/Assets/Tests/Editor/CompileStatusBridgeCommandTests.cs b/Assets/Tests/Editor/CompileStatusBridgeCommandTests.cs index a6545cb63..5a4b1ac00 100644 --- a/Assets/Tests/Editor/CompileStatusBridgeCommandTests.cs +++ b/Assets/Tests/Editor/CompileStatusBridgeCommandTests.cs @@ -158,7 +158,7 @@ public void BuildResponse_WhenUnityIsIdleAndPendingRequestObservedReload_Returns Assert.That(response.Ready, Is.True); Assert.That(response.HasResult, Is.True); - Assert.That(response.Result["Success"]?.Type, Is.EqualTo(JTokenType.Null)); + Assert.That(response.Result["Success"]?.Value(), Is.False); Assert.That(response.Result["ErrorCount"]?.Type, Is.EqualTo(JTokenType.Null)); Assert.That(response.Result["Warnings"]?.Type, Is.EqualTo(JTokenType.Null)); Assert.That(response.Result["Message"]?.ToString(), Does.Contain("reloaded scripts")); @@ -216,11 +216,13 @@ public void BuildResponse_WhenForceCompilePendingRequestHasNoResult_ReturnsExpla Assert.That(response.Ready, Is.True); Assert.That(response.HasResult, Is.True); - Assert.That(response.Result["Success"]?.Type, Is.EqualTo(JTokenType.Null)); + Assert.That(response.Result["Success"]?.Value(), Is.False); Assert.That(response.Result["ErrorCount"]?.Type, Is.EqualTo(JTokenType.Null)); Assert.That( response.Result["Message"]?.ToString(), Is.EqualTo(ForceCompileUnknownResult.MessageText)); + Assert.That(response.Result["ErrorCode"]?.ToString(), Is.EqualTo(ForceCompileUnknownResult.ErrorCodeText)); + Assert.That(response.Result["NextActions"]?.Type, Is.EqualTo(JTokenType.Array)); } } diff --git a/Assets/Tests/Editor/JsonRpcRequestProcessorCliVersionGateTests.cs b/Assets/Tests/Editor/JsonRpcRequestProcessorCliVersionGateTests.cs index 5b0525a20..f313be772 100644 --- a/Assets/Tests/Editor/JsonRpcRequestProcessorCliVersionGateTests.cs +++ b/Assets/Tests/Editor/JsonRpcRequestProcessorCliVersionGateTests.cs @@ -923,7 +923,6 @@ public void InvalidateCache() private sealed class SingleFlightTestResponse : UnityCliLoopToolResponse { - public bool Success { get; set; } = true; } } } diff --git a/Assets/Tests/Editor/JsonRpcResponseFactoryWireShapeCharacterizationTests.cs b/Assets/Tests/Editor/JsonRpcResponseFactoryWireShapeCharacterizationTests.cs index 83a1eec10..099a1dd83 100644 --- a/Assets/Tests/Editor/JsonRpcResponseFactoryWireShapeCharacterizationTests.cs +++ b/Assets/Tests/Editor/JsonRpcResponseFactoryWireShapeCharacterizationTests.cs @@ -143,7 +143,6 @@ private static string BuildToolRequest(string toolName, int id) private sealed class DeterministicSuccessResponse : UnityCliLoopToolResponse { - public bool Success { get; set; } = true; } private sealed class DeterministicSuccessTool : IUnityCliLoopTool diff --git a/Assets/Tests/Editor/ToolExecutionSessionTests.cs b/Assets/Tests/Editor/ToolExecutionSessionTests.cs index a69af9420..ab0205763 100644 --- a/Assets/Tests/Editor/ToolExecutionSessionTests.cs +++ b/Assets/Tests/Editor/ToolExecutionSessionTests.cs @@ -256,7 +256,6 @@ public void InvalidateCache() private sealed class SessionTestResponse : UnityCliLoopToolResponse { - public bool Success { get; set; } = true; } } } diff --git a/Packages/src/Editor/FirstPartyTools/ClearConsole/ClearConsoleResponse.cs b/Packages/src/Editor/FirstPartyTools/ClearConsole/ClearConsoleResponse.cs index bc18c13ef..aad42ab50 100644 --- a/Packages/src/Editor/FirstPartyTools/ClearConsole/ClearConsoleResponse.cs +++ b/Packages/src/Editor/FirstPartyTools/ClearConsole/ClearConsoleResponse.cs @@ -10,11 +10,6 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// public class ClearConsoleResponse : UnityCliLoopToolResponse { - /// - /// Whether the console clear operation was successful - /// - public bool Success { get; set; } - /// /// Number of logs that were cleared from the console /// @@ -101,4 +96,4 @@ public ClearedLogCounts(int errorCount, int warningCount, int logCount) LogCount = logCount; } } -} \ No newline at end of file +} diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileResponse.cs b/Packages/src/Editor/FirstPartyTools/Compile/CompileResponse.cs index 386fe6c89..a3cd64c8e 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/CompileResponse.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileResponse.cs @@ -27,13 +27,6 @@ public CompileIssue() { } /// public class CompileResponse : UnityCliLoopToolResponse { - /// - /// Whether compilation was successful. - /// Null is used when the tool cannot reliably determine the result yet (e.g., forced recompile/domain reload), - /// to avoid misleading clients into treating "0 errors" as a confirmed success. - /// - public bool? Success { get; set; } - /// /// Number of compilation errors. /// Null is used when the tool intentionally does not provide details (e.g., forced recompile), @@ -67,6 +60,8 @@ public class CompileResponse : UnityCliLoopToolResponse /// public string Message { get; set; } + public string ErrorCode { get; set; } + /// /// Optional recovery steps when compile cannot proceed automatically. /// @@ -82,7 +77,7 @@ public class CompileResponse : UnityCliLoopToolResponse /// Create a new CompileResponse /// public CompileResponse( - bool? success, + bool success, int? errorCount, int? warningCount, CompileIssue[] errors, diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileResponseFactory.cs b/Packages/src/Editor/FirstPartyTools/Compile/CompileResponseFactory.cs index e8f66c774..03816741c 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/CompileResponseFactory.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileResponseFactory.cs @@ -39,7 +39,7 @@ internal static CompileResponse CreateResponse( if (result.IsIndeterminate) { return new CompileResponse( - success: result.Success, + success: result.Success == true, errorCount: result.ErrorCount, warningCount: result.WarningCount, errors: null, @@ -48,7 +48,7 @@ internal static CompileResponse CreateResponse( } CompileResponse response = new CompileResponse( - success: result.Success, + success: result.Success == true, errorCount: result.Errors?.Length ?? 0, warningCount: result.Warnings?.Length ?? 0, errors: ToIssues(result.Errors), @@ -75,14 +75,17 @@ private static string[] CreateExternalSceneChangeNextActions(string message) private static CompileResponse CreateForceCompileResult(CompileResult result) { - ForceCompileUnknownResult unknownResult = ForceCompileUnknownResult.Create(result.Success); - return new CompileResponse( + ForceCompileUnknownResult unknownResult = ForceCompileUnknownResult.Create(); + CompileResponse response = new CompileResponse( success: unknownResult.Success, errorCount: unknownResult.ErrorCount, warningCount: unknownResult.WarningCount, errors: null, warnings: null, message: unknownResult.Message); + response.ErrorCode = ForceCompileUnknownResult.ErrorCodeText; + response.NextActions = new[] { ForceCompileUnknownResult.NextActionText }; + return response; } private static CompileIssue[] ToIssues(UnityEditor.Compilation.CompilerMessage[] messages) diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeResponse.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeResponse.cs index 1160bbb5b..883c50778 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeResponse.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeResponse.cs @@ -12,9 +12,6 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// public class ExecuteDynamicCodeResponse : UnityCliLoopToolResponse, IUnityCliLoopTimingResponse { - /// Execution success flag - public bool Success { get; set; } - /// Execution result public string Result { get; set; } diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs index a422660c2..be944c4c3 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs @@ -46,9 +46,6 @@ public class ClearPausePointSchema : UnityCliLoopToolSchema /// public class PausePointResponse : UnityCliLoopToolResponse { - // Defaults to true so existing snapshot/clear paths keep their prior semantics. - // Only explicit validation failures set this to false. - public bool Success { get; set; } = true; public string Id { get; set; } = string.Empty; public int ResolvedLine { get; set; } public string ResolvedMethod { get; set; } = string.Empty; diff --git a/Packages/src/Editor/FirstPartyTools/Raycast/RaycastResponse.cs b/Packages/src/Editor/FirstPartyTools/Raycast/RaycastResponse.cs index 0e911c2a5..38fac4519 100644 --- a/Packages/src/Editor/FirstPartyTools/Raycast/RaycastResponse.cs +++ b/Packages/src/Editor/FirstPartyTools/Raycast/RaycastResponse.cs @@ -9,7 +9,6 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// public class RaycastResponse : UnityCliLoopToolResponse { - public bool Success { get; set; } public string Message { get; set; } = ""; public bool Hit { get; set; } public string? HitGameObjectName { get; set; } diff --git a/Packages/src/Editor/FirstPartyTools/RecordInput/RecordInputResponse.cs b/Packages/src/Editor/FirstPartyTools/RecordInput/RecordInputResponse.cs index 85cdebdcb..b0f770aca 100644 --- a/Packages/src/Editor/FirstPartyTools/RecordInput/RecordInputResponse.cs +++ b/Packages/src/Editor/FirstPartyTools/RecordInput/RecordInputResponse.cs @@ -9,7 +9,6 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// public class RecordInputResponse : UnityCliLoopToolResponse { - public bool Success { get; set; } public string Message { get; set; } = ""; public string Action { get; set; } = ""; public string? OutputPath { get; set; } diff --git a/Packages/src/Editor/FirstPartyTools/ReplayInput/ReplayInputResponse.cs b/Packages/src/Editor/FirstPartyTools/ReplayInput/ReplayInputResponse.cs index c9dfdab6e..7b50abd38 100644 --- a/Packages/src/Editor/FirstPartyTools/ReplayInput/ReplayInputResponse.cs +++ b/Packages/src/Editor/FirstPartyTools/ReplayInput/ReplayInputResponse.cs @@ -9,7 +9,6 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// public class ReplayInputResponse : UnityCliLoopToolResponse { - public bool Success { get; set; } public string Message { get; set; } = ""; public string Action { get; set; } = ""; public string? InputPath { get; set; } diff --git a/Packages/src/Editor/FirstPartyTools/RunTests/RunTestsResponse.cs b/Packages/src/Editor/FirstPartyTools/RunTests/RunTestsResponse.cs index 2b5b38ca1..01f02f060 100644 --- a/Packages/src/Editor/FirstPartyTools/RunTests/RunTestsResponse.cs +++ b/Packages/src/Editor/FirstPartyTools/RunTests/RunTestsResponse.cs @@ -19,11 +19,6 @@ public class RunTestsResponse : UnityCliLoopToolResponse public static readonly string TestFrameworkUnavailableMessage = $"run-tests requires the Unity Test Framework package ({UnityCliLoopConstants.PACKAGE_NAME_TEST_FRAMEWORK}). Install it via Package Manager to use test execution."; - /// - /// Whether test execution was successful - /// - public bool Success { get; set; } - /// /// Machine-readable execution status /// diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotUseCase.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotUseCase.cs index caf2ee381..fccb6626f 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotUseCase.cs @@ -57,7 +57,12 @@ private async Task CaptureRenderingAsync( "CaptureMode.rendering requires PlayMode", correlationId: correlationId ); - return new ScreenshotResponse(); + return new ScreenshotResponse + { + Success = false, + Message = "Rendering screenshots require PlayMode, but Unity is currently in EditMode.", + NextActions = new[] { "Start PlayMode with `uloop control-play-mode --action Play`, then retry the rendering screenshot." } + }; } List annotatedElements = new(); @@ -193,7 +198,12 @@ private async Task CaptureRenderingAsync( "Play Mode view RenderTexture is not available. Open the Game view or Device Simulator and wait for a frame before retrying.", correlationId: correlationId ); - return new ScreenshotResponse(); + return new ScreenshotResponse + { + Success = false, + Message = "PlayMode rendering did not produce an image.", + NextActions = new[] { "Open the Game view or Device Simulator, wait for a frame, then retry the rendering screenshot." } + }; } int width = texture.width; @@ -296,7 +306,9 @@ private async Task CaptureWindowsAsync( ); return new ScreenshotResponse { + Success = false, Message = notFoundMessage, + NextActions = new[] { "Open the requested Unity window, then retry the screenshot." } }; } @@ -399,8 +411,10 @@ internal static ScreenshotResponse CreateTimedOutResult( return new ScreenshotResponse { + Success = false, TimedOut = true, Message = message, + NextActions = new[] { "Retry the screenshot after Unity finishes rendering the requested frame." }, Screenshots = screenshots, }; } diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs index 4b5166ed8..d60d6d706 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardResponse.cs @@ -11,7 +11,6 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// public class SimulateKeyboardResponse : UnityCliLoopToolResponse { - public bool Success { get; set; } public string Message { get; set; } = ""; public string Action { get; set; } = ""; public string? KeyName { get; set; } diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputResponse.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputResponse.cs index 6af19e50b..72982359e 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputResponse.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputResponse.cs @@ -11,7 +11,6 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// public class SimulateMouseInputResponse : UnityCliLoopToolResponse { - public bool Success { get; set; } public string Message { get; set; } = ""; public string Action { get; set; } = ""; public string? Button { get; set; } diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/SimulateMouseUiResponse.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/SimulateMouseUiResponse.cs index efbff5159..66fd34b28 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/SimulateMouseUiResponse.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/SimulateMouseUiResponse.cs @@ -11,7 +11,6 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// public class SimulateMouseUiResponse : UnityCliLoopToolResponse { - public bool Success { get; set; } public string Message { get; set; } = ""; public string Action { get; set; } = ""; public string? HitGameObjectName { get; set; } diff --git a/Packages/src/Editor/FirstPartyTools/Watch/WatchTools.cs b/Packages/src/Editor/FirstPartyTools/Watch/WatchTools.cs index 188b861a8..47487829f 100644 --- a/Packages/src/Editor/FirstPartyTools/Watch/WatchTools.cs +++ b/Packages/src/Editor/FirstPartyTools/Watch/WatchTools.cs @@ -40,7 +40,6 @@ public sealed class GetWatchValuesSchema : UnityCliLoopToolSchema /// public sealed class WatchResponse : UnityCliLoopToolResponse { - public bool Success { get; set; } = true; public string Id { get; set; } = string.Empty; public string Expression { get; set; } = string.Empty; public int MaxHistory { get; set; } diff --git a/Packages/src/Editor/Infrastructure/Api/CompileStatusBridgeCommand.cs b/Packages/src/Editor/Infrastructure/Api/CompileStatusBridgeCommand.cs index e1bb3c865..68e9a04fd 100644 --- a/Packages/src/Editor/Infrastructure/Api/CompileStatusBridgeCommand.cs +++ b/Packages/src/Editor/Infrastructure/Api/CompileStatusBridgeCommand.cs @@ -164,18 +164,20 @@ private static JObject CreateRecoveredCompileResult( if (pendingRequest.ForceRecompile) { ForceCompileUnknownResult unknownForceCompileResult = - ForceCompileUnknownResult.Create(null); + ForceCompileUnknownResult.Create(); message = unknownForceCompileResult.Message; } return new JObject { - ["Success"] = JValue.CreateNull(), + ["Success"] = false, ["ErrorCount"] = JValue.CreateNull(), ["WarningCount"] = JValue.CreateNull(), ["Errors"] = JValue.CreateNull(), ["Warnings"] = JValue.CreateNull(), ["Message"] = message, + ["ErrorCode"] = ForceCompileUnknownResult.ErrorCodeText, + ["NextActions"] = new JArray(ForceCompileUnknownResult.NextActionText), ["ProjectRoot"] = UnityCliLoopPathResolver.GetProjectRoot() }; } diff --git a/Packages/src/Editor/ToolContracts/ForceCompileUnknownResult.cs b/Packages/src/Editor/ToolContracts/ForceCompileUnknownResult.cs index 06c2db07a..01e0623ed 100644 --- a/Packages/src/Editor/ToolContracts/ForceCompileUnknownResult.cs +++ b/Packages/src/Editor/ToolContracts/ForceCompileUnknownResult.cs @@ -5,24 +5,26 @@ namespace io.github.hatayama.UnityCliLoop.ToolContracts /// public sealed class ForceCompileUnknownResult { - public const string MessageText = "Forced full compilation completed. Unity does not return a definitive compile result for this forced full compile path, so fields that Unity did not provide are intentionally null; run get-logs to inspect the compiler output."; + public const string MessageText = "Forced full compilation was triggered, but Unity did not provide a definitive result after domain reload."; + public const string ErrorCodeText = "COMPILE_RESULT_UNKNOWN"; + public const string NextActionText = "Wait for domain reload to complete, then run `uloop compile` without --force-recompile to obtain a definitive result."; - private ForceCompileUnknownResult(bool? success) + private ForceCompileUnknownResult() { - Success = success; + Success = false; ErrorCount = null; WarningCount = null; Message = MessageText; } - public bool? Success { get; } + public bool Success { get; } public int? ErrorCount { get; } public int? WarningCount { get; } public string Message { get; } - public static ForceCompileUnknownResult Create(bool? success) + public static ForceCompileUnknownResult Create() { - return new ForceCompileUnknownResult(success); + return new ForceCompileUnknownResult(); } } } diff --git a/Packages/src/Editor/ToolContracts/ScreenshotResponse.cs b/Packages/src/Editor/ToolContracts/ScreenshotResponse.cs index a9a6d5498..34943cc71 100644 --- a/Packages/src/Editor/ToolContracts/ScreenshotResponse.cs +++ b/Packages/src/Editor/ToolContracts/ScreenshotResponse.cs @@ -37,6 +37,7 @@ public class ScreenshotResponse : UnityCliLoopToolResponse public List Screenshots { get; set; } = new List(); public bool TimedOut { get; set; } public string Message { get; set; } = ""; + public string[] NextActions { get; set; } = new string[0]; public int ScreenshotCount => Screenshots.Count; } diff --git a/Packages/src/Editor/ToolContracts/UnityCliLoopToolResponse.cs b/Packages/src/Editor/ToolContracts/UnityCliLoopToolResponse.cs index 224597d6b..7d2736ef4 100644 --- a/Packages/src/Editor/ToolContracts/UnityCliLoopToolResponse.cs +++ b/Packages/src/Editor/ToolContracts/UnityCliLoopToolResponse.cs @@ -5,6 +5,7 @@ namespace io.github.hatayama.UnityCliLoop.ToolContracts /// public abstract class UnityCliLoopToolResponse { + public bool Success { get; set; } = true; } public interface IUnityCliLoopTimingResponse diff --git a/cli/dispatcher/internal/dispatcher/launch_ready.go b/cli/dispatcher/internal/dispatcher/launch_ready.go index 2eb6bdea4..6b84a8a42 100644 --- a/cli/dispatcher/internal/dispatcher/launch_ready.go +++ b/cli/dispatcher/internal/dispatcher/launch_ready.go @@ -16,7 +16,7 @@ const ( launchReadyMessage = "Unity CLI Loop is ready." launchAlreadyRunningReadyMessage = "Unity is already running and ready." launchStoppedMessage = "Unity process stopped." - launchNoProcessMessage = "No Unity process is running for this project." + launchNoProcessMessage = "No matching Unity process was found; it may have already exited." ) type launchReadyResponse struct { diff --git a/cli/dispatcher/internal/dispatcher/launch_test.go b/cli/dispatcher/internal/dispatcher/launch_test.go index 2e269c734..89050dda2 100644 --- a/cli/dispatcher/internal/dispatcher/launch_test.go +++ b/cli/dispatcher/internal/dispatcher/launch_test.go @@ -254,7 +254,7 @@ func TestRunLaunchQuitDoesNotLaunchWhenUnityIsNotRunning(t *testing.T) { if code != 0 { t.Fatalf("exit code mismatch: %d stderr=%s", code, stderr.String()) } - if !strings.Contains(stdout.String(), "No Unity process is running") { + if !strings.Contains(stdout.String(), launchNoProcessMessage) { t.Fatalf("stdout mismatch: %s", stdout.String()) } } diff --git a/cli/project-runner/internal/projectrunner/compile_wait.go b/cli/project-runner/internal/projectrunner/compile_wait.go index ef00a0ce1..515b9a0cf 100644 --- a/cli/project-runner/internal/projectrunner/compile_wait.go +++ b/cli/project-runner/internal/projectrunner/compile_wait.go @@ -40,6 +40,7 @@ type compileCompletionOptions struct { } type compileStatusResponse struct { + Success bool `json:"Success"` Ready bool `json:"Ready"` HasResult bool `json:"HasResult"` IsCompiling bool `json:"IsCompiling"` diff --git a/cli/project-runner/internal/projectrunner/compile_wait_test.go b/cli/project-runner/internal/projectrunner/compile_wait_test.go index c37d9bcc7..7c80852b3 100644 --- a/cli/project-runner/internal/projectrunner/compile_wait_test.go +++ b/cli/project-runner/internal/projectrunner/compile_wait_test.go @@ -270,7 +270,7 @@ func TestWaitForCompileCompletionForceCompileWaitsForStoredResult(t *testing.T) return compileStatusResponse{ Ready: true, HasResult: true, - Result: json.RawMessage(`{"Success":null,"ErrorCount":null,"Message":"Force compilation completed"}`), + Result: json.RawMessage(`{"Success":false,"ErrorCount":null,"Message":"Force compilation completed"}`), }, nil }) @@ -295,8 +295,8 @@ func TestWaitForCompileCompletionForceCompileWaitsForStoredResult(t *testing.T) if err := json.Unmarshal(result, &payload); err != nil { t.Fatalf("force compile result is not JSON: %v", err) } - if payload["Success"] != nil { - t.Fatalf("force compile success should be unknown: %#v", payload["Success"]) + if payload["Success"] != false { + t.Fatalf("force compile must fail closed when the result is unknown: %#v", payload["Success"]) } if payload["ErrorCount"] != nil || payload["WarningCount"] != nil { t.Fatalf("force compile counts should be unknown: %#v", payload) @@ -472,8 +472,8 @@ func TestRunCompileWithDomainReloadWaitWritesRequestLifecycleVibeLogs(t *testing var stderr bytes.Buffer code := runCompileWithDomainReloadWaitWithDeps(context.Background(), connection, params, &stdout, &stderr, deps) - if code != 0 { - t.Fatalf("runCompileWithDomainReloadWait failed: code=%d stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + if code != 1 { + t.Fatalf("expected failed compile envelope to exit 1: code=%d stdout=%s stderr=%s", code, stdout.String(), stderr.String()) } logContent := readOnlyCliVibeLog(t, projectRoot) @@ -529,7 +529,7 @@ func TestCompileResultReadinessWaitMode(t *testing.T) { cases := map[string]compileReadinessWaitMode{ `{"Success":true}`: compileReadinessWaitWarmup, `{"Success":false,"Errors":[{"Message":"boom"}]}`: compileReadinessWaitNone, - `{"Success":null,"Message":"indeterminate"}`: compileReadinessWaitNone, + `{"Success":false,"Message":"indeterminate"}`: compileReadinessWaitNone, `{"Message":"indeterminate"}`: compileReadinessWaitNone, } diff --git a/cli/project-runner/internal/projectrunner/pause_point_logs.go b/cli/project-runner/internal/projectrunner/pause_point_logs.go index e49c84e75..1b98887bf 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_logs.go +++ b/cli/project-runner/internal/projectrunner/pause_point_logs.go @@ -72,6 +72,7 @@ type pausePointWaitResult struct { } type pausePointGetLogsResponse struct { + Success bool `json:"Success"` TotalCount int `json:"TotalCount"` DisplayedCount int `json:"DisplayedCount"` LogType string `json:"LogType"` diff --git a/cli/project-runner/internal/projectrunner/pause_point_types.go b/cli/project-runner/internal/projectrunner/pause_point_types.go index a5618c338..b48cce949 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_types.go +++ b/cli/project-runner/internal/projectrunner/pause_point_types.go @@ -1,6 +1,7 @@ package projectrunner type pausePointStatusResponse struct { + Success bool `json:"Success"` Id string `json:"Id"` Status string `json:"Status"` IsEnabled bool `json:"IsEnabled"` diff --git a/cli/project-runner/internal/projectrunner/run.go b/cli/project-runner/internal/projectrunner/run.go index 078ea7ade..88947a858 100644 --- a/cli/project-runner/internal/projectrunner/run.go +++ b/cli/project-runner/internal/projectrunner/run.go @@ -92,9 +92,10 @@ func runTool(ctx context.Context, connection unityipc.Connection, command string }) return 1 } - clicore.WriteJSON(stdout, stripDebugTimingResult(command, outcome.Result)) + result := stripDebugTimingResult(command, outcome.Result) + clicore.WriteJSON(stdout, result) writeDebugTiming(stderr, command, time.Since(startedAt), outcome) - return 0 + return toolEnvelopeExitCode(result) } func runExecuteDynamicCodeWithDomainReloadWait(ctx context.Context, connection unityipc.Connection, params map[string]any, stdout io.Writer, stderr io.Writer) int { @@ -143,9 +144,10 @@ func runExecuteDynamicCodeWithDomainReloadWait(ctx context.Context, connection u spinner.Stop() result := stripExecuteDynamicCodeControlResult(outcome.Result) - clicore.WriteJSON(stdout, stripDebugTimingResult(clicore.ExecuteDynamicCodeCommandName, result)) + result = stripDebugTimingResult(clicore.ExecuteDynamicCodeCommandName, result) + clicore.WriteJSON(stdout, result) writeDebugTiming(stderr, clicore.ExecuteDynamicCodeCommandName, time.Since(startedAt), outcome) - return 0 + return toolEnvelopeExitCode(result) } func runCompileWithDomainReloadWait(ctx context.Context, connection unityipc.Connection, params map[string]any, stdout io.Writer, stderr io.Writer) int { @@ -227,7 +229,7 @@ func runCompileWithDomainReloadWaitWithDeps( spinner.Stop() clicore.WriteJSON(stdout, result) writeDebugTiming(stderr, clicore.CompileCommandName, time.Since(startedAt), outcome) - return 0 + return toolEnvelopeExitCode(result) } func writePostCompileWarmupWarning(stderr io.Writer, err error) { diff --git a/cli/project-runner/internal/projectrunner/tool_envelope_exit_code.go b/cli/project-runner/internal/projectrunner/tool_envelope_exit_code.go new file mode 100644 index 000000000..f294e7b21 --- /dev/null +++ b/cli/project-runner/internal/projectrunner/tool_envelope_exit_code.go @@ -0,0 +1,18 @@ +package projectrunner + +import "encoding/json" + +type toolEnvelopeSuccess struct { + Success *bool `json:"Success"` +} + +func toolEnvelopeExitCode(result []byte) int { + var envelope toolEnvelopeSuccess + if err := json.Unmarshal(result, &envelope); err != nil || envelope.Success == nil { + return 1 + } + if !*envelope.Success { + return 1 + } + return 0 +} diff --git a/cli/project-runner/internal/projectrunner/tool_envelope_exit_code_test.go b/cli/project-runner/internal/projectrunner/tool_envelope_exit_code_test.go new file mode 100644 index 000000000..59050958f --- /dev/null +++ b/cli/project-runner/internal/projectrunner/tool_envelope_exit_code_test.go @@ -0,0 +1,38 @@ +package projectrunner + +import "testing" + +// Verifies a true tool envelope reports a successful process exit. +func TestToolEnvelopeExitCodeReturnsZeroForSuccess(t *testing.T) { + if code := toolEnvelopeExitCode([]byte(`{"Success":true}`)); code != 0 { + t.Fatalf("expected exit code 0, got %d", code) + } +} + +// Verifies a false tool envelope reports a failed process exit. +func TestToolEnvelopeExitCodeReturnsOneForFailure(t *testing.T) { + if code := toolEnvelopeExitCode([]byte(`{"Success":false}`)); code != 1 { + t.Fatalf("expected exit code 1, got %d", code) + } +} + +// Verifies a missing Success field fails closed. +func TestToolEnvelopeExitCodeRejectsMissingSuccess(t *testing.T) { + if code := toolEnvelopeExitCode([]byte(`{"Message":"missing"}`)); code != 1 { + t.Fatalf("expected exit code 1, got %d", code) + } +} + +// Verifies a non-boolean Success field fails closed. +func TestToolEnvelopeExitCodeRejectsNonBooleanSuccess(t *testing.T) { + if code := toolEnvelopeExitCode([]byte(`{"Success":"true"}`)); code != 1 { + t.Fatalf("expected exit code 1, got %d", code) + } +} + +// Verifies malformed tool JSON fails closed. +func TestToolEnvelopeExitCodeRejectsMalformedJSON(t *testing.T) { + if code := toolEnvelopeExitCode([]byte(`{"Success":`)); code != 1 { + t.Fatalf("expected exit code 1, got %d", code) + } +} diff --git a/tests/contracts/compile_status_response_contract.json b/tests/contracts/compile_status_response_contract.json index 243ff5839..e5366231c 100644 --- a/tests/contracts/compile_status_response_contract.json +++ b/tests/contracts/compile_status_response_contract.json @@ -1,4 +1,5 @@ { + "Success": true, "Ready": true, "HasResult": true, "IsCompiling": true, @@ -11,6 +12,7 @@ "Errors": [], "Warnings": [], "Message": "Compile result is available.", + "ErrorCode": null, "NextActions": null, "ProjectRoot": null }, diff --git a/tests/contracts/get_logs_response_contract.json b/tests/contracts/get_logs_response_contract.json index 9aa779f2b..ea16cbe91 100644 --- a/tests/contracts/get_logs_response_contract.json +++ b/tests/contracts/get_logs_response_contract.json @@ -1,4 +1,5 @@ { + "Success": true, "TotalCount": 2, "DisplayedCount": 1, "LogType": "Error", diff --git a/tests/contracts/pause_point_status_response_contract.json b/tests/contracts/pause_point_status_response_contract.json index 6668b4647..00abc8053 100644 --- a/tests/contracts/pause_point_status_response_contract.json +++ b/tests/contracts/pause_point_status_response_contract.json @@ -1,4 +1,5 @@ { + "Success": true, "Id": "Assets/Scripts/Enemy.cs:42", "Status": "Hit", "IsEnabled": true,