Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -786,7 +786,19 @@ private void UpdateContinueState()
ErrorText.Visibility = Visibility.Collapsed;
}

private int TimeoutForCurrentStep() => WizardTimeouts.ForStep(_currentTitle, _currentMessage);
private int TimeoutForCurrentStep()
{
IReadOnlyCollection<WizardOptionValue>? 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()
{
Expand Down
11 changes: 9 additions & 2 deletions src/OpenClaw.SetupEngine/SetupWizardRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ async Task<JsonElement> 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)
Expand Down Expand Up @@ -478,7 +478,14 @@ private static bool TryGetConfiguredAnswer(WizardPayload step, Dictionary<string
return false;
}

private static int TimeoutFor(WizardPayload step) => 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)
{
Expand Down
84 changes: 75 additions & 9 deletions src/OpenClaw.SetupEngine/WizardTimeouts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ public static class WizardTimeouts
/// <summary>Default per-step wizard request timeout.</summary>
public const int DefaultTimeoutMs = 30_000;

/// <summary>Extended timeout for steps that wait on external auth.</summary>
public const int AuthTimeoutMs = 300_000;
/// <summary>Extended timeout for steps that wait on auth or external setup work.</summary>
public const int SlowStepTimeoutMs = 300_000;

/// <summary>Polling delay for gateway progress/status wizard steps.</summary>
public static readonly TimeSpan ProgressPollDelay = TimeSpan.FromSeconds(1);
Expand All @@ -24,16 +24,82 @@ public static class WizardTimeouts
"browser", "authenticate", "verification",
};

/// <summary>Auth-style steps get <see cref="AuthTimeoutMs"/>; others get <see cref="DefaultTimeoutMs"/>.</summary>
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",
};

/// <summary>
/// Auth and external setup steps get <see cref="SlowStepTimeoutMs"/>; ordinary
/// questions get <see cref="DefaultTimeoutMs"/>. Only selected option metadata
/// is considered so an unselected slow integration does not extend every choice.
/// </summary>
public static int ForStep(
string? title,
string? message,
string? stepId = null,
IReadOnlyCollection<WizardOptionValue>? 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<WizardOptionValue> options,
string? answer = null)
{
var category = WizardStepClassifier.Categorize(stepType, options.Count > 0);
IReadOnlyCollection<WizardOptionValue>? 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<WizardOptionValue> options)
{
var parts = new List<string?>();
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<string> hints) =>
hints.Any(hint => text.Contains(hint, StringComparison.OrdinalIgnoreCase));
}
88 changes: 86 additions & 2 deletions tests/OpenClaw.SetupEngine.Tests/WizardTimeoutsTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Text.Json;

namespace OpenClaw.SetupEngine.Tests;

public class WizardTimeoutsTests
Expand All @@ -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")]
Expand All @@ -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()
{
Expand Down
9 changes: 9 additions & 0 deletions tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down