diff --git a/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml.cs b/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml.cs index 2d440b996..02a6c56d3 100644 --- a/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml.cs +++ b/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml.cs @@ -306,7 +306,7 @@ private async Task ApplyPayloadAsync(JsonElement payload) payload = await _client.SendWizardRequestAsync( "wizard.next", WizardNextPayload.Acknowledge(_sessionId, _stepId), - timeoutMs: WizardTimeouts.ForStep(title, message)); + timeoutMs: WizardTimeouts.ForStep(title, message, _stepId)); if (generation != _operationGeneration || _errorState || _client == null) return; @@ -786,7 +786,19 @@ private void UpdateContinueState() ErrorText.Visibility = Visibility.Collapsed; } - private int TimeoutForCurrentStep() => WizardTimeouts.ForStep(_currentTitle, _currentMessage); + private int TimeoutForCurrentStep() + { + IReadOnlyCollection? selectedOptions = null; + if (WizardSelection.RequiresSelection(_stepType)) + { + var selectedValues = GetSelectedOptionValues().ToHashSet(StringComparer.Ordinal); + selectedOptions = _options + .Where(option => selectedValues.Contains(option.Value)) + .ToArray(); + } + + return WizardTimeouts.ForStep(_currentTitle, _currentMessage, _stepId, selectedOptions); + } private void ResetInputs() { diff --git a/src/OpenClaw.SetupEngine/SetupWizardRunner.cs b/src/OpenClaw.SetupEngine/SetupWizardRunner.cs index 90e4a22bd..39a24719e 100644 --- a/src/OpenClaw.SetupEngine/SetupWizardRunner.cs +++ b/src/OpenClaw.SetupEngine/SetupWizardRunner.cs @@ -231,7 +231,7 @@ async Task SendWizardNextAsync(object parameters, int timeoutMs) } : WizardNextPayload.Acknowledge(sessionId, parsed.StepId); - payload = await SendWizardNextAsync(parameters, TimeoutFor(parsed)); + payload = await SendWizardNextAsync(parameters, TimeoutFor(parsed, answerResult.Answer)); } } catch (OperationCanceledException) @@ -478,7 +478,14 @@ private static bool TryGetConfiguredAnswer(WizardPayload step, Dictionary WizardTimeouts.ForStep(step.Title, step.Message); + private static int TimeoutFor(WizardPayload step, string? answer = null) + => WizardTimeouts.ForGatewayStep( + step.Title, + step.Message, + step.StepId, + step.StepType, + step.Options, + answer); private static bool IsRestartLikeWizardDisconnect(Exception ex) { diff --git a/src/OpenClaw.SetupEngine/WizardTimeouts.cs b/src/OpenClaw.SetupEngine/WizardTimeouts.cs index 4550e4d70..727020b78 100644 --- a/src/OpenClaw.SetupEngine/WizardTimeouts.cs +++ b/src/OpenClaw.SetupEngine/WizardTimeouts.cs @@ -6,8 +6,8 @@ public static class WizardTimeouts /// Default per-step wizard request timeout. public const int DefaultTimeoutMs = 30_000; - /// Extended timeout for steps that wait on external auth. - public const int AuthTimeoutMs = 300_000; + /// Extended timeout for steps that wait on auth or external setup work. + public const int SlowStepTimeoutMs = 300_000; /// Polling delay for gateway progress/status wizard steps. public static readonly TimeSpan ProgressPollDelay = TimeSpan.FromSeconds(1); @@ -24,16 +24,82 @@ public static class WizardTimeouts "browser", "authenticate", "verification", }; - /// Auth-style steps get ; others get . - public static int ForStep(string? title, string? message) + private static readonly string[] s_slowSetupHints = { - var text = $"{title} {message}"; - foreach (var hint in s_authHints) + "plugin", "install", "download", "teams", + }; + + /// + /// Auth and external setup steps get ; ordinary + /// questions get . Only selected option metadata + /// is considered so an unselected slow integration does not extend every choice. + /// + public static int ForStep( + string? title, + string? message, + string? stepId = null, + IReadOnlyCollection? selectedOptions = null) + { + var promptText = JoinText(title, message); + if (HasAnyHint(promptText, s_authHints)) + return SlowStepTimeoutMs; + + // With a choice step, only the submitted option proves which operation + // will run. This keeps "skip" and unrelated options on the short path. + var slowText = selectedOptions is null + ? JoinText(title, message, stepId) + : JoinOptionText(selectedOptions); + if (HasAnyHint(slowText, s_slowSetupHints)) + return SlowStepTimeoutMs; + + return DefaultTimeoutMs; + } + + internal static int ForGatewayStep( + string? title, + string? message, + string? stepId, + string? stepType, + IReadOnlyList options, + string? answer = null) + { + var category = WizardStepClassifier.Categorize(stepType, options.Count > 0); + IReadOnlyCollection? selectedOptions = + category == WizardStepCategory.RequiresAnswer && options.Count > 0 ? [] : null; + + if (selectedOptions is not null && !string.IsNullOrWhiteSpace(answer)) { - if (text.Contains(hint, StringComparison.OrdinalIgnoreCase)) - return AuthTimeoutMs; + if (string.Equals(stepType, "multiselect", StringComparison.OrdinalIgnoreCase) + && WizardAnswerBuilder.TryResolveOptions(options, answer, out var multiselectOptions)) + { + selectedOptions = multiselectOptions; + } + else if (!string.Equals(stepType, "multiselect", StringComparison.OrdinalIgnoreCase) + && WizardAnswerBuilder.TryFindOption(options, answer, out var selectedOption)) + { + selectedOptions = [selectedOption]; + } } - return DefaultTimeoutMs; + return ForStep(title, message, stepId, selectedOptions); } + + private static string JoinOptionText(IEnumerable options) + { + var parts = new List(); + foreach (var option in options) + { + parts.Add(option.Value); + parts.Add(option.Label); + parts.Add(option.Hint); + } + + return JoinText(parts.ToArray()); + } + + private static string JoinText(params string?[] parts) => + string.Join(' ', parts.Where(part => !string.IsNullOrWhiteSpace(part))); + + private static bool HasAnyHint(string text, IEnumerable hints) => + hints.Any(hint => text.Contains(hint, StringComparison.OrdinalIgnoreCase)); } diff --git a/tests/OpenClaw.SetupEngine.Tests/WizardTimeoutsTests.cs b/tests/OpenClaw.SetupEngine.Tests/WizardTimeoutsTests.cs index f47acf96a..44a8eb650 100644 --- a/tests/OpenClaw.SetupEngine.Tests/WizardTimeoutsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/WizardTimeoutsTests.cs @@ -1,3 +1,5 @@ +using System.Text.Json; + namespace OpenClaw.SetupEngine.Tests; public class WizardTimeoutsTests @@ -10,17 +12,45 @@ public class WizardTimeoutsTests [InlineData("Enter the verification code")] public void AuthSteps_GetExtendedTimeout(string text) { - Assert.Equal(WizardTimeouts.AuthTimeoutMs, WizardTimeouts.ForStep(text, string.Empty)); + Assert.Equal(WizardTimeouts.SlowStepTimeoutMs, WizardTimeouts.ForStep(text, string.Empty)); } [Fact] public void AuthHint_DetectedInMessage() { Assert.Equal( - WizardTimeouts.AuthTimeoutMs, + WizardTimeouts.SlowStepTimeoutMs, WizardTimeouts.ForStep("Setup", "Visit the device authorization page")); } + [Theory] + [InlineData("Setup", "Downloading plugin package", "")] + [InlineData("Setup", "Installing integration", "")] + [InlineData("Setup", "Working", "install-channel-plugin")] + public void SlowSetupSteps_GetExtendedTimeout(string title, string message, string stepId) + { + Assert.Equal( + WizardTimeouts.SlowStepTimeoutMs, + WizardTimeouts.ForStep(title, message, stepId)); + } + + [Theory] + [InlineData("opaque-value", "Microsoft Teams", "")] + [InlineData("teams", "Collaboration", "")] + [InlineData("opaque-value", "Collaboration", "Download and configure the plugin")] + public void SelectedSlowOptionMetadata_GetsExtendedTimeout(string value, string label, string hint) + { + var selected = new WizardOptionValue( + value, + label, + hint, + JsonSerializer.SerializeToElement(value)); + + Assert.Equal( + WizardTimeouts.SlowStepTimeoutMs, + WizardTimeouts.ForStep("Choose an integration", "Pick one.", selectedOptions: [selected])); + } + [Theory] [InlineData("Choose a connector")] [InlineData("Enter a friendly name")] @@ -30,6 +60,60 @@ public void OrdinarySteps_GetDefaultTimeout(string text) Assert.Equal(WizardTimeouts.DefaultTimeoutMs, WizardTimeouts.ForStep(text, string.Empty)); } + [Fact] + public void OrdinarySelectedOption_KeepsDefaultTimeout() + { + var selected = new WizardOptionValue( + "matrix", + "Matrix", + "Configure an existing connection", + JsonSerializer.SerializeToElement("matrix")); + + Assert.Equal( + WizardTimeouts.DefaultTimeoutMs, + WizardTimeouts.ForStep("Choose an integration", "Pick one.", selectedOptions: [selected])); + } + + [Theory] + [InlineData("__skip__", "Skip for now", "")] + [InlineData("matrix", "Matrix", "Existing connection")] + [InlineData("browser", "Open in browser", "")] + public void ChannelSelector_NonSlowOption_KeepsDefaultTimeout(string value, string label, string hint) + { + var selected = new WizardOptionValue( + value, + label, + hint, + JsonSerializer.SerializeToElement(value)); + + Assert.Equal( + WizardTimeouts.DefaultTimeoutMs, + WizardTimeouts.ForStep( + "Choose a channel", + "Select where OpenClaw should send messages.", + "select-channel-quickstart", + [selected])); + } + + [Fact] + public void ProgressStep_WithIncidentalOptions_UsesStepMetadata() + { + var incidentalOption = new WizardOptionValue( + "details", + "Show details", + "", + JsonSerializer.SerializeToElement("details")); + + Assert.Equal( + WizardTimeouts.SlowStepTimeoutMs, + WizardTimeouts.ForGatewayStep( + "Setup", + "Working", + "install-channel-plugin", + "progress", + [incidentalOption])); + } + [Fact] public void ProgressPollBudget_AllowsSingleLongSetupStepToUseTotalBudget() { diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index 6f9474432..870abb364 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -597,6 +597,15 @@ public void WizardSecondaryButton_DoesNotSkipEntireWizardInErrorState() Assert.Contains("SendCurrentAnswerAsync(skip: true)", method); } + [Fact] + public void WizardProgressPolling_UsesStepIdForTimeoutClassification() + { + var root = TestRepositoryPaths.GetRepositoryRoot(); + var source = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages", "WizardPage.xaml.cs")); + + Assert.Contains("WizardTimeouts.ForStep(title, message, _stepId)", source); + } + [Fact] public void WizardCompletion_AppliesWindowsNodeContextBeforeSummary() {