From c6b73ad1237e92ee7ff730ccbea70ee66ecd4991 Mon Sep 17 00:00:00 2001 From: Oleksandr Mandryk Date: Sat, 5 Sep 2026 02:15:32 -0400 Subject: [PATCH] fix(setup): allow optional wizard text input --- .../Pages/WizardPage.xaml.cs | 21 ++++++++++++++--- .../WizardPayloadHelpers.cs | 23 +++++++++++++++++++ src/OpenClaw.SetupEngine/WizardSelection.cs | 7 ++++-- .../WizardSelectionTests.cs | 11 ++++----- .../AppRefactorContractTests.cs | 15 ++++++++++++ .../WizardPayloadHelpersTests.cs | 22 ++++++++++++++++++ 6 files changed, 88 insertions(+), 11 deletions(-) diff --git a/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml.cs b/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml.cs index 27abbb48d..3a3fa2b0d 100644 --- a/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml.cs +++ b/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml.cs @@ -298,6 +298,20 @@ private async Task ApplyPayloadAsync(JsonElement payload) return; } + var validationError = WizardPayloadHelpers.ExtractCurrentStepValidationError(payload, _stepId); + if (!string.IsNullOrWhiteSpace(validationError)) + { + BusyRing.Visibility = Visibility.Collapsed; + BusyRing.IsActive = false; + StatusText.Text = "A few quick questions to connect your agent"; + SecondaryButton.IsEnabled = true; + ShowRecoveryActions(); + UpdateContinueState(); + ErrorText.Text = validationError; + ErrorText.Visibility = Visibility.Visible; + return; + } + if (!payload.TryGetProperty("step", out var step)) { ShowError("Gateway wizard returned an invalid response."); @@ -851,7 +865,8 @@ private async Task SendCurrentAnswerAsync(bool skip) if (generation != _operationGeneration) return; - AppendTranscriptTurn(answeredQuestion, answeredLabel); + if (string.IsNullOrWhiteSpace(WizardPayloadHelpers.ExtractCurrentStepValidationError(payload, _stepId))) + AppendTranscriptTurn(answeredQuestion, answeredLabel); await ApplyPayloadAsync(payload); ScrollActiveIntoView(); } @@ -1056,7 +1071,7 @@ private bool TryBuildAnswerValue(out object value) return true; if (_stepType == "text") - return !WizardSelection.ShouldDisableContinue(_stepType, value?.ToString()); + return WizardSelection.CanSubmitTextAnswer(_stepType); return !WizardSelection.ShouldDisableContinue(_stepType, GetSelectedOptionValues(), _options.Select(o => o.Value).ToArray()); } @@ -1083,7 +1098,7 @@ private void UpdateContinueState() return; PrimaryButton.IsEnabled = _stepType == "text" - ? !WizardSelection.ShouldDisableContinue(_stepType, _sensitive ? SecretInput.Password : TextInput.Text) + ? WizardSelection.CanSubmitTextAnswer(_stepType) : !WizardSelection.ShouldDisableContinue( _stepType, GetSelectedOptionValues(), diff --git a/src/OpenClaw.SetupEngine.UI/WizardPayloadHelpers.cs b/src/OpenClaw.SetupEngine.UI/WizardPayloadHelpers.cs index e4731ed31..8c1d65246 100644 --- a/src/OpenClaw.SetupEngine.UI/WizardPayloadHelpers.cs +++ b/src/OpenClaw.SetupEngine.UI/WizardPayloadHelpers.cs @@ -8,6 +8,29 @@ namespace OpenClaw.SetupEngine.UI; /// internal static class WizardPayloadHelpers { + /// + /// Returns a validation error only when the gateway kept the client on the + /// current step. This distinguishes rejected answers from terminal errors. + /// + public static string ExtractCurrentStepValidationError(JsonElement payload, string currentStepId) + { + if (payload.ValueKind != JsonValueKind.Object + || string.IsNullOrWhiteSpace(currentStepId) + || !payload.TryGetProperty("error", out var error) + || error.ValueKind != JsonValueKind.String + || string.IsNullOrWhiteSpace(error.GetString()) + || !payload.TryGetProperty("step", out var step) + || step.ValueKind != JsonValueKind.Object + || !step.TryGetProperty("id", out var id) + || id.ValueKind != JsonValueKind.String + || !string.Equals(id.GetString(), currentStepId, StringComparison.Ordinal)) + { + return string.Empty; + } + + return error.GetString()!; + } + /// /// Reads the message field of a wizard step. Upstream is supposed to /// send a string; the Gemini CLI OAuth plugin (and possibly others) nests a diff --git a/src/OpenClaw.SetupEngine/WizardSelection.cs b/src/OpenClaw.SetupEngine/WizardSelection.cs index 483b3a0f6..634c8c6ef 100644 --- a/src/OpenClaw.SetupEngine/WizardSelection.cs +++ b/src/OpenClaw.SetupEngine/WizardSelection.cs @@ -39,6 +39,9 @@ public static bool HasValidSelection(string stepType, IReadOnlyCollection selectedValues, IReadOnlyCollection optionValues) => RequiresSelection(stepType) && !HasValidSelection(stepType, selectedValues, optionValues); - public static bool ShouldDisableContinue(string stepType, string? textInput) => - stepType == "text" && string.IsNullOrWhiteSpace(textInput); + // Text validation belongs to the gateway because the wire protocol does not + // expose whether a text step is required. Always submit the current value so + // optional empty answers can advance and required fields can return their + // authoritative validation message. + public static bool CanSubmitTextAnswer(string stepType) => stepType == "text"; } diff --git a/tests/OpenClaw.SetupEngine.Tests/WizardSelectionTests.cs b/tests/OpenClaw.SetupEngine.Tests/WizardSelectionTests.cs index 52db8b93c..05640945e 100644 --- a/tests/OpenClaw.SetupEngine.Tests/WizardSelectionTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/WizardSelectionTests.cs @@ -57,12 +57,11 @@ public void EmptyAcknowledgeSteps_AllowContinue(string stepType) } [Theory] - [InlineData(null, true)] - [InlineData("", true)] - [InlineData(" ", true)] - [InlineData("value", false)] - public void ContinueDisabled_ForEmptyTextInput(string? input, bool expectedDisabled) + [InlineData("text", true)] + [InlineData("select", false)] + [InlineData("note", false)] + public void TextAnswers_AreSubmittedForGatewayValidation(string stepType, bool expected) { - Assert.Equal(expectedDisabled, WizardSelection.ShouldDisableContinue("text", input)); + Assert.Equal(expected, WizardSelection.CanSubmitTextAnswer(stepType)); } } diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index be17a3181..07eb6943c 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -1479,6 +1479,21 @@ public void WizardProgressPolling_UsesStepIdForTimeoutClassification() Assert.Contains("WizardTimeouts.ForStep(title, message, _stepId)", source); } + [Fact] + public void WizardValidationError_RemainsVisibleAfterContinueStateRefresh() + { + var root = TestRepositoryPaths.GetRepositoryRoot(); + var source = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages", "WizardPage.xaml.cs")); + var applyPayload = ExtractMethod(source, "ApplyPayloadAsync"); + + AssertInOrder( + applyPayload, + "ExtractCurrentStepValidationError", + "UpdateContinueState();", + "ErrorText.Text = validationError;", + "ErrorText.Visibility = Visibility.Visible;"); + } + [Fact] public void WizardResetInputs_RemovesOverflowMoreButton() { diff --git a/tests/OpenClaw.Tray.Tests/WizardPayloadHelpersTests.cs b/tests/OpenClaw.Tray.Tests/WizardPayloadHelpersTests.cs index d40845a69..3405ab1c3 100644 --- a/tests/OpenClaw.Tray.Tests/WizardPayloadHelpersTests.cs +++ b/tests/OpenClaw.Tray.Tests/WizardPayloadHelpersTests.cs @@ -9,6 +9,28 @@ public class WizardPayloadHelpersTests private static JsonElement Parse(string json) => JsonDocument.Parse(json).RootElement; + [Fact] + public void ExtractCurrentStepValidationError_returns_error_for_rejected_answer() + { + var payload = Parse("""{"done":false,"error":"Model ID is required","step":{"id":"model","type":"text"}}"""); + + Assert.Equal( + "Model ID is required", + WizardPayloadHelpers.ExtractCurrentStepValidationError(payload, "model")); + } + + [Theory] + [InlineData("other")] + [InlineData("")] + public void ExtractCurrentStepValidationError_ignores_non_current_steps(string currentStepId) + { + var payload = Parse("""{"done":false,"error":"Model ID is required","step":{"id":"model","type":"text"}}"""); + + Assert.Equal( + string.Empty, + WizardPayloadHelpers.ExtractCurrentStepValidationError(payload, currentStepId)); + } + // ---- ExtractStepMessage ----------------------------------------------- [Fact]