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]