diff --git a/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml.cs b/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml.cs index c574d6d94..15417467e 100644 --- a/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml.cs +++ b/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using System.Text.RegularExpressions; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Documents; @@ -16,15 +15,24 @@ public sealed partial class WizardPage : Page { private const int MaxWizardSteps = 50; private const int MaxSameStepVisits = 3; + + // Bound progress polling separately from interactive wizard steps. + private SetupConfig? _config; private OpenClawGatewayClient? _client; private string _sessionId = ""; private string _stepId = ""; private string _stepType = ""; + private string _currentTitle = ""; + private string _currentMessage = ""; + private string _lastProgressStepId = ""; + private WizardStepCategory _stepCategory = WizardStepCategory.Acknowledge; private bool _sensitive; private bool _errorState; private int _operationGeneration; private int _wizardStepCount; + private int _progressPolls; + private int _totalProgressPolls; private readonly Dictionary _stepVisits = new(StringComparer.OrdinalIgnoreCase); private readonly List _options = []; // Tails the WSL gateway log and surfaces openclaw plugin console.log output @@ -68,6 +76,9 @@ private async Task StartWizardAsync() ClearConsoleBanner(); _sessionId = ""; _wizardStepCount = 0; + _progressPolls = 0; + _totalProgressPolls = 0; + _lastProgressStepId = ""; _stepVisits.Clear(); SetBusy("Connecting to gateway..."); _client = await ConnectClientAsync(); @@ -166,103 +177,187 @@ private void OnWizardClientStatusChanged(object? sender, ConnectionStatus status private async Task ApplyPayloadAsync(JsonElement payload) { - if (payload.TryGetProperty("sessionId", out var sid)) - _sessionId = sid.GetString() ?? _sessionId; + var generation = _operationGeneration; - if (payload.TryGetProperty("done", out var done) && done.ValueKind == JsonValueKind.True) + while (true) { - var error = payload.TryGetProperty("error", out var err) ? err.ToString() : ""; - if (!string.IsNullOrWhiteSpace(error) && !error.Contains("this.prompt is not a function", StringComparison.OrdinalIgnoreCase)) + if (payload.TryGetProperty("sessionId", out var sid)) + _sessionId = sid.GetString() ?? _sessionId; + + if (payload.TryGetProperty("done", out var done) && done.ValueKind == JsonValueKind.True) { - ShowError(error); + var error = payload.TryGetProperty("error", out var err) ? err.ToString() : ""; + if (!string.IsNullOrWhiteSpace(error) && !error.Contains("this.prompt is not a function", StringComparison.OrdinalIgnoreCase)) + { + ShowError(error); + return; + } + + await DisconnectAsync(); + if (generation != _operationGeneration || _errorState) + return; + + if (_config!.SkipPermissions) + SetupWindow.Active?.NavigateToComplete(true, TimeSpan.Zero, _config.LogPath); + else + SetupWindow.Active?.NavigateToPermissions(); return; } - await DisconnectAsync(); - if (_config!.SkipPermissions) - SetupWindow.Active?.NavigateToComplete(true, TimeSpan.Zero, _config.LogPath); - else - SetupWindow.Active?.NavigateToPermissions(); - return; - } + if (!payload.TryGetProperty("step", out var step)) + { + ShowError("Gateway wizard returned an invalid response."); + return; + } - if (!payload.TryGetProperty("step", out var step)) - { - ShowError("Gateway wizard returned an invalid response."); - return; - } + _stepId = step.TryGetProperty("id", out var id) ? id.ToString() : ""; + var rawType = step.TryGetProperty("type", out var type) ? type.ToString() : "note"; + _stepType = string.IsNullOrWhiteSpace(rawType) ? "note" : rawType.Trim().ToLowerInvariant(); + var stepIndex = payload.TryGetProperty("stepIndex", out var indexProperty) && indexProperty.TryGetInt32(out var index) ? index : 0; + _sensitive = step.TryGetProperty("sensitive", out var sensitive) && sensitive.ValueKind == JsonValueKind.True; + var title = step.TryGetProperty("title", out var titleProp) ? titleProp.ToString() : ""; + var message = WizardPayloadHelpers.ExtractStepMessage(step); + var initial = step.TryGetProperty("initialValue", out var initialProp) ? initialProp : default; + var hasOptions = StepHasOptions(step); + _stepCategory = WizardStepClassifier.Categorize(_stepType, hasOptions); + + if (_stepCategory == WizardStepCategory.RequiresAnswer + && hasOptions + && _stepType is not ("select" or "multiselect" or "text")) + { + _stepType = "select"; + } - _stepId = step.TryGetProperty("id", out var id) ? id.ToString() : ""; - _stepType = step.TryGetProperty("type", out var type) ? type.ToString() : "note"; - var stepIndex = payload.TryGetProperty("stepIndex", out var indexProperty) && indexProperty.TryGetInt32(out var index) ? index : 0; - _sensitive = step.TryGetProperty("sensitive", out var sensitive) && sensitive.ValueKind == JsonValueKind.True; - var title = step.TryGetProperty("title", out var titleProp) ? titleProp.ToString() : ""; - var message = WizardPayloadHelpers.ExtractStepMessage(step); - var initial = step.TryGetProperty("initialValue", out var initialProp) ? initialProp : default; + // Keep raw text for auth timeout selection; rendered URL/code rows are not TextBlocks. + _currentTitle = title; + _currentMessage = message; - if (string.IsNullOrWhiteSpace(_stepId)) - { - ShowError("Gateway wizard step is missing an id."); - return; - } + if (string.IsNullOrWhiteSpace(_stepId)) + { + ShowError("Gateway wizard step is missing an id."); + return; + } - _wizardStepCount++; - if (_wizardStepCount > MaxWizardSteps) - { - ShowError($"Gateway wizard exceeded {MaxWizardSteps} steps."); - return; - } + // Progress carries no answer; poll until the gateway emits the next step. + if (_stepCategory == WizardStepCategory.Progress) + { + if (!string.Equals(_stepId, _lastProgressStepId, StringComparison.Ordinal)) + { + _lastProgressStepId = _stepId; + _progressPolls = 0; + } - var visitKey = $"{_stepId}:{stepIndex}"; - _stepVisits.TryGetValue(visitKey, out var visits); - _stepVisits[visitKey] = visits + 1; - if (_stepVisits[visitKey] > MaxSameStepVisits) - { - ShowError($"Gateway wizard repeated step '{_stepId}' too many times."); - return; - } + _progressPolls++; + _totalProgressPolls++; + if (_progressPolls > WizardTimeouts.MaxProgressPollsPerStep) + { + ShowError($"Gateway wizard progress step '{_stepId}' did not complete after {WizardTimeouts.MaxProgressPollsPerStep} updates."); + return; + } + if (_totalProgressPolls > WizardTimeouts.MaxTotalProgressPolls) + { + ShowError($"Gateway wizard did not finish after {WizardTimeouts.MaxTotalProgressPolls} progress updates."); + return; + } - ResetInputs(); - TitleText.Text = string.IsNullOrWhiteSpace(title) ? DisplayTitleFor(_stepType) : title; - RenderMessage(message); - StepCard.MinHeight = _stepType == "note" && string.IsNullOrWhiteSpace(message) ? 140 : 260; - ErrorText.Visibility = Visibility.Collapsed; - BusyRing.Visibility = Visibility.Collapsed; - BusyRing.IsActive = false; - ShowRecoveryActions(); - StatusText.Text = "Answer the gateway setup question"; - PrimaryButton.IsEnabled = !WizardSelection.RequiresAnswer(_stepType); - SecondaryButton.IsEnabled = true; - PrimaryButton.Content = _stepType == "confirm" ? "Yes" : "Continue"; - SecondaryButton.Content = "No"; - SecondaryButton.Visibility = _stepType == "confirm" ? Visibility.Visible : Visibility.Collapsed; + RenderProgressStep(title, message); + await Task.Delay(WizardTimeouts.ProgressPollDelay); + if (generation != _operationGeneration || _errorState || _client == null) + return; - if (!BuildOptions(step, initial)) - return; + payload = await _client.SendWizardRequestAsync( + "wizard.next", + WizardNextPayload.Acknowledge(_sessionId, _stepId), + timeoutMs: WizardTimeouts.ForStep(title, message)); - if (_stepType == "text") - { - if (_sensitive) + if (generation != _operationGeneration || _errorState || _client == null) + return; + + continue; + } + + _wizardStepCount++; + if (_wizardStepCount > MaxWizardSteps) { - SecretInput.Visibility = Visibility.Visible; - SecretInput.Password = initial.ValueKind == JsonValueKind.String ? initial.GetString() ?? "" : ""; + ShowError($"Gateway wizard exceeded {MaxWizardSteps} steps."); + return; } - else + + var visitKey = $"{_stepId}:{stepIndex}"; + _stepVisits.TryGetValue(visitKey, out var visits); + _stepVisits[visitKey] = visits + 1; + if (_stepVisits[visitKey] > MaxSameStepVisits) { - TextInput.Visibility = Visibility.Visible; - TextInput.Text = initial.ValueKind == JsonValueKind.String ? initial.GetString() ?? "" : ""; + ShowError($"Gateway wizard repeated step '{_stepId}' too many times."); + return; } - UpdateContinueState(); - } + ResetInputs(); + TitleText.Text = string.IsNullOrWhiteSpace(title) ? DisplayTitleFor(_stepType) : title; + RenderMessage(message); + StepCard.MinHeight = _stepType == "note" && string.IsNullOrWhiteSpace(message) ? 140 : 260; + ErrorText.Visibility = Visibility.Collapsed; + BusyRing.Visibility = Visibility.Collapsed; + BusyRing.IsActive = false; + ShowRecoveryActions(); + StatusText.Text = "Answer the gateway setup question"; + PrimaryButton.IsEnabled = !WizardSelection.RequiresAnswer(_stepType); + SecondaryButton.IsEnabled = true; + PrimaryButton.Content = _stepType == "confirm" ? "Yes" : "Continue"; + SecondaryButton.Content = "No"; + SecondaryButton.Visibility = _stepType == "confirm" ? Visibility.Visible : Visibility.Collapsed; + + if (!BuildOptions(step, initial)) + return; - if (_stepType == "note") - { - SecondaryButton.IsEnabled = false; - SecondaryButton.Visibility = Visibility.Collapsed; + if (_stepType == "text") + { + if (_sensitive) + { + SecretInput.Visibility = Visibility.Visible; + SecretInput.Password = initial.ValueKind == JsonValueKind.String ? initial.GetString() ?? "" : ""; + } + else + { + TextInput.Visibility = Visibility.Visible; + TextInput.Text = initial.ValueKind == JsonValueKind.String ? initial.GetString() ?? "" : ""; + } + + UpdateContinueState(); + } + + if (_stepType == "note") + { + SecondaryButton.IsEnabled = false; + SecondaryButton.Visibility = Visibility.Collapsed; + } + + return; } } + private static bool StepHasOptions(JsonElement step) => + step.TryGetProperty("options", out var options) + && options.ValueKind == JsonValueKind.Array + && options.EnumerateArray().Any(); + + private void RenderProgressStep(string title, string message) + { + ResetInputs(); + TitleText.Text = string.IsNullOrWhiteSpace(title) ? "Working…" : title; + RenderMessage(message); + StepCard.MinHeight = 200; + ErrorText.Visibility = Visibility.Collapsed; + BusyRing.Visibility = Visibility.Visible; + BusyRing.IsActive = true; + StatusText.Text = string.IsNullOrWhiteSpace(message) ? "Working…" : "Setting things up…"; + PrimaryButton.IsEnabled = false; + PrimaryButton.Content = "Continue"; + SecondaryButton.IsEnabled = false; + SecondaryButton.Visibility = Visibility.Collapsed; + ShowRecoveryActions(); + } + private bool BuildOptions(JsonElement step, JsonElement initial) { if (_stepType is not ("select" or "multiselect")) @@ -459,6 +554,10 @@ private async Task SendCurrentAnswerAsync(bool skip) ? new { sessionId = _sessionId, answer = new { stepId = _stepId, value = false } } : new { sessionId = _sessionId }; } + else if (_stepCategory == WizardStepCategory.NonInteractive) + { + parameters = WizardNextPayload.Acknowledge(_sessionId, _stepId); + } else { parameters = new { sessionId = _sessionId, answer = new { stepId = _stepId, value = answerValue } }; @@ -542,17 +641,7 @@ private void UpdateContinueState() ErrorText.Visibility = Visibility.Collapsed; } - private int TimeoutForCurrentStep() - { - var text = $"{TitleText.Text} {string.Join(' ', MessagePanel.Children.OfType().Select(t => t.Text))}"; - return text.Contains("device", StringComparison.OrdinalIgnoreCase) - || text.Contains("authorize", StringComparison.OrdinalIgnoreCase) - || text.Contains("login", StringComparison.OrdinalIgnoreCase) - || text.Contains("sign in", StringComparison.OrdinalIgnoreCase) - || text.Contains("oauth", StringComparison.OrdinalIgnoreCase) - ? 300_000 - : 30_000; - } + private int TimeoutForCurrentStep() => WizardTimeouts.ForStep(_currentTitle, _currentMessage); private void ResetInputs() { @@ -577,29 +666,26 @@ private void RenderMessage(string message) } // Renders a single line into a target panel, decorating URLs as hyperlinks - // and "Code: XXX" patterns as monospace rows with a copy button. Shared by - // RenderMessage and AppendConsoleLine. + // and "Code: XXX" patterns as monospace rows with a copy button. private void AppendLineTo(Panel target, string line, double fontSize, double opacity) { - var trimmed = line.TrimEnd('\r'); + var segment = WizardMessageFormatting.ClassifyLine(line); - var codeMatch = Regex.Match(trimmed, @"^((?:Code|code|user_code|USER_CODE)\s*[:=]\s*)([A-Z0-9]{2,8}(?:-[A-Z0-9]{2,8})+|[A-Z0-9]{4,12})\b"); - if (codeMatch.Success) + if (segment.Kind == WizardLineKind.Code) { - target.Children.Add(BuildCodeRow(codeMatch.Groups[1].Value, codeMatch.Groups[2].Value)); + target.Children.Add(BuildCodeRow(segment.Prefix, segment.Highlight)); return; } - var urlMatch = Regex.Match(trimmed, @"https?://[^\s\)\""]+", RegexOptions.IgnoreCase); - if (urlMatch.Success && Uri.TryCreate(urlMatch.Value.TrimEnd('.', ','), UriKind.Absolute, out var uri)) + if (segment.Kind == WizardLineKind.Url && Uri.TryCreate(segment.Highlight, UriKind.Absolute, out var uri)) { - target.Children.Add(BuildLinkLine(trimmed, urlMatch.Value, uri)); + target.Children.Add(BuildLinkLine(segment.Text, segment.Highlight, uri)); return; } target.Children.Add(new TextBlock { - Text = trimmed, + Text = segment.Text, FontSize = fontSize, FontFamily = new FontFamily("Consolas"), Opacity = opacity, @@ -803,6 +889,8 @@ private async Task EnterWizardErrorAsync(string detail) if (_errorState) return; + // Invalidate in-flight wizard.next calls before tearing down the connection. + AdvanceOperationGeneration(); _errorState = true; // Cancel the server-side wizard session before disconnecting so that // subsequent retries (Start wizard again / Skip wizard) don't hit a diff --git a/src/OpenClaw.SetupEngine/SetupWizardRunner.cs b/src/OpenClaw.SetupEngine/SetupWizardRunner.cs index 2dadbdd3f..90e4a22bd 100644 --- a/src/OpenClaw.SetupEngine/SetupWizardRunner.cs +++ b/src/OpenClaw.SetupEngine/SetupWizardRunner.cs @@ -10,6 +10,10 @@ public sealed class SetupWizardRunner private const int MaxWizardSteps = 50; private const int MaxSameStepVisits = 3; private static readonly Regex s_normalizeKeyRegex = new("[^a-z0-9]+", RegexOptions.Compiled); + + // Progress steps can repeat while background work runs; keep bounded caps + // so setup fails with a diagnostic instead of hanging. + private readonly SetupContext _ctx; public SetupWizardRunner(SetupContext ctx) @@ -84,7 +88,45 @@ public async Task RunAsync(CancellationToken ct) var visits = new Dictionary(StringComparer.OrdinalIgnoreCase); var restartAttempts = 0; - for (var i = 0; i < MaxWizardSteps; i++) + var progressPolls = 0; + var totalProgressPolls = 0; + var lastProgressStepId = ""; + var interactiveSteps = 0; + + // A reconnect restarts the wizard session, so reset replay-scoped + // counters before processing the replacement start payload. + async Task SendWizardNextAsync(object parameters, int timeoutMs) + { + try + { + return await client!.SendWizardRequestAsync("wizard.next", parameters, timeoutMs); + } + catch (Exception ex) when (!ct.IsCancellationRequested && IsRestartLikeWizardDisconnect(ex) && restartAttempts < 2) + { + restartAttempts++; + _ctx.Logger.Warn($"Gateway restarted during wizard; reconnecting and replaying answers (attempt {restartAttempts}/2): {ex.Message}"); + + try { await client!.DisconnectAsync(); } catch { } + client!.Dispose(); + + await Task.Delay(TimeSpan.FromSeconds(3), ct); + client = CreateWizardClient(credential, identityPath, wsLogger); + var reconnect = await PairOperatorStep.WaitForConnectionOrPairing(client, _ctx, TimeSpan.FromSeconds(30), ct); + if (reconnect != PairOperatorStep.ConnectionOutcome.Connected) + throw new WizardFatalException($"Gateway wizard reconnect failed after restart: {reconnect}"); + + sessionId = ""; + visits.Clear(); + discoveredSteps.Clear(); + interactiveSteps = 0; + progressPolls = 0; + totalProgressPolls = 0; + lastProgressStepId = ""; + return await client.SendWizardRequestAsync("wizard.start", timeoutMs: 30_000); + } + } + + while (true) { ct.ThrowIfCancellationRequested(); @@ -123,6 +165,39 @@ public async Task RunAsync(CancellationToken ct) if (string.IsNullOrWhiteSpace(parsed.StepId)) return StepResult.Fail("Gateway wizard step is missing an id."); + var category = WizardStepClassifier.Categorize(parsed.StepType, parsed.Options.Count > 0); + + // Progress carries no answer; poll until the gateway emits the + // next interactive step or reaches a bounded failure. + if (category == WizardStepCategory.Progress) + { + if (!string.Equals(parsed.StepId, lastProgressStepId, StringComparison.Ordinal)) + { + lastProgressStepId = parsed.StepId; + progressPolls = 0; + } + + progressPolls++; + totalProgressPolls++; + if (progressPolls > WizardTimeouts.MaxProgressPollsPerStep) + return StepResult.Fail($"Gateway wizard progress step '{parsed.StepId}' did not complete after {WizardTimeouts.MaxProgressPollsPerStep} polls."); + if (totalProgressPolls > WizardTimeouts.MaxTotalProgressPolls) + return StepResult.Fail($"Gateway wizard did not finish after {WizardTimeouts.MaxTotalProgressPolls} progress updates."); + + var progressText = $"{parsed.Title} {parsed.Message}".Trim(); + _ctx.Logger.Info(string.IsNullOrWhiteSpace(progressText) + ? $"Wizard progress step '{parsed.StepId}' — polling for next step" + : $"Wizard progress: {progressText}"); + + await Task.Delay(WizardTimeouts.ProgressPollDelay, ct); + payload = await SendWizardNextAsync(WizardNextPayload.Acknowledge(sessionId, parsed.StepId), TimeoutFor(parsed)); + continue; + } + + interactiveSteps++; + if (interactiveSteps > MaxWizardSteps) + return StepResult.Fail($"Gateway wizard exceeded {MaxWizardSteps} steps."); + var visitKey = $"{parsed.StepId}:{parsed.StepIndex}"; visits.TryGetValue(visitKey, out var visitCount); visits[visitKey] = visitCount + 1; @@ -142,9 +217,9 @@ public async Task RunAsync(CancellationToken ct) _ctx.Logger.Info(answerResult.HasAnswer ? $"Wizard step '{parsed.StepId}' ({parsed.StepType}, key={StableAnswerKey(parsed.Title, parsed.Message, parsed.StepId)}) answered with {(parsed.Sensitive ? "[sensitive]" : $"'{answerResult.Answer}'")}" - : $"Wizard step '{parsed.StepId}' ({parsed.StepType}) continuing without explicit answer"); + : $"Wizard step '{parsed.StepId}' ({parsed.StepType}, {category}) continuing without explicit answer"); - var parameters = answerResult.HasAnswer + object parameters = answerResult.HasAnswer ? new { sessionId, @@ -154,35 +229,10 @@ public async Task RunAsync(CancellationToken ct) value = AnswerValueForWire(parsed, answerResult.Answer) } } - : (object)new { sessionId }; + : WizardNextPayload.Acknowledge(sessionId, parsed.StepId); - try - { - payload = await client.SendWizardRequestAsync("wizard.next", parameters, timeoutMs: TimeoutFor(parsed)); - } - catch (Exception ex) when (!ct.IsCancellationRequested && IsRestartLikeWizardDisconnect(ex) && restartAttempts < 2) - { - restartAttempts++; - _ctx.Logger.Warn($"Gateway restarted during wizard; reconnecting and replaying answers (attempt {restartAttempts}/2): {ex.Message}"); - - // slopwatch-ignore: SW003 Cleanup is best-effort; failure cannot improve caller state and the original outcome is preserved. - try { await client.DisconnectAsync(); } catch { } - client.Dispose(); - - await Task.Delay(TimeSpan.FromSeconds(3), ct); - client = CreateWizardClient(credential, identityPath, wsLogger); - var reconnect = await PairOperatorStep.WaitForConnectionOrPairing(client, _ctx, TimeSpan.FromSeconds(30), ct); - if (reconnect != PairOperatorStep.ConnectionOutcome.Connected) - return StepResult.Fail($"Gateway wizard reconnect failed after restart: {reconnect}"); - - sessionId = ""; - visits.Clear(); - discoveredSteps.Clear(); - payload = await client.SendWizardRequestAsync("wizard.start", timeoutMs: 30_000); - } + payload = await SendWizardNextAsync(parameters, TimeoutFor(parsed)); } - - return StepResult.Fail($"Gateway wizard exceeded {MaxWizardSteps} steps."); } catch (OperationCanceledException) { @@ -190,6 +240,10 @@ public async Task RunAsync(CancellationToken ct) await TryCancelWizardAsync(client, sessionId); throw; } + catch (WizardFatalException ex) + { + return StepResult.Fail(ex.Message, ex); + } catch (Exception ex) { return StepResult.Fail($"Gateway wizard failed: {ex.Message}", ex); @@ -225,6 +279,7 @@ private async Task TryCancelWizardAsync(OpenClawGatewayClient client, string ses _ctx.Logger.Warn("Cancelling gateway wizard session"); await client.SendWizardRequestAsync("wizard.cancel", new { sessionId }, timeoutMs: 10_000); } + catch (Exception ex) { _ctx.Logger.Warn($"Failed to cancel gateway wizard session: {ex.Message}"); @@ -291,19 +346,34 @@ private static AnswerResolution ResolveAnswer(WizardPayload step, Dictionary 0); + if (WizardStepClassifier.ContinuesWithoutAnswer(category)) { - "note" => "true", - "confirm" => InferConfirmAnswer(step), - "select" => InferOptionAnswer(step), - "multiselect" => InferOptionAnswer(step), - "text" => InferTextAnswer(step), - _ => !string.IsNullOrWhiteSpace(step.InitialValue) ? step.InitialValue : null - }; + return AnswerResolution.Continue(); + } + + switch (category) + { + case WizardStepCategory.Acknowledge: + return AnswerResolution.Ok("true"); + + case WizardStepCategory.Confirm: + return ValidateAnswer(step, InferConfirmAnswer(step), configuredAnswer: false); + } + + // Unknown types with options are choice prompts for wire-shaping purposes. + var inferred = step.Options.Count > 0 && step.StepType != "text" + ? InferOptionAnswer(step) + : step.StepType switch + { + "select" or "multiselect" => InferOptionAnswer(step), + "text" => InferTextAnswer(step), + _ => !string.IsNullOrWhiteSpace(step.InitialValue) ? step.InitialValue : null + }; if (inferred == null) { - return AnswerResolution.Fail($"Gateway wizard step '{step.StepId}' ({step.StepType}) requires a text answer."); + return AnswerResolution.Fail($"Gateway wizard step '{step.StepId}' ({step.StepType}) requires a value that was not provided."); } return ValidateAnswer(step, inferred, configuredAnswer: false); @@ -337,7 +407,11 @@ private static string InferConfirmAnswer(WizardPayload step) private static object AnswerValueForWire(WizardPayload step, string answer) { - return WizardAnswerBuilder.BuildWireValue(step.StepType, answer, step.Options); + // Preserve the selected option's raw JSON value for unknown choice-style steps. + var effectiveType = step.Options.Count > 0 && step.StepType is not ("select" or "multiselect" or "text") + ? "select" + : step.StepType; + return WizardAnswerBuilder.BuildWireValue(effectiveType, answer, step.Options); } private static string? InferTextAnswer(WizardPayload step) @@ -404,17 +478,7 @@ private static bool TryGetConfiguredAnswer(WizardPayload step, Dictionary WizardTimeouts.ForStep(step.Title, step.Message); private static bool IsRestartLikeWizardDisconnect(Exception ex) { @@ -434,7 +498,11 @@ private static string AnswerPlaceholderFor(WizardTemplateStep step) { "select" => step.Options.FirstOrDefault()?.Value ?? "