diff --git a/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml.cs b/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml.cs index 30d950a49..f6906e52f 100644 --- a/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml.cs +++ b/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml.cs @@ -7,6 +7,7 @@ using Microsoft.UI.Xaml.Media; using Microsoft.UI.Xaml.Navigation; using OpenClaw.Connection; +using OpenClaw.SetupEngine; using OpenClaw.Shared; using OpenClaw.SetupEngine.UI; using Windows.ApplicationModel.DataTransfer; @@ -47,6 +48,10 @@ private sealed record WizardConnection(OpenClawGatewayClient Client, NativeGatew private int _wizardStepCount; private int _progressPolls; private int _totalProgressPolls; + private int _stepIndex; + private int _totalSteps; + private bool _stepHasOptions; + private readonly WizardFinalStepTracker _finalStepTracker = new WizardFinalStepTracker(); private readonly Dictionary _stepVisits = new(StringComparer.OrdinalIgnoreCase); private readonly List _options = []; private volatile bool _expectedTerminalRestart; @@ -176,6 +181,7 @@ private async Task StartWizardCoreAsync(bool clearTranscript) _totalProgressPolls = 0; _lastProgressStepId = ""; _stepVisits.Clear(); + _finalStepTracker.ResetForNewSession(); SetBusy("Connecting to gateway..."); var connection = await ConnectClientAsync(); if (generation != _operationGeneration) @@ -326,8 +332,15 @@ private async Task ApplyPayloadAsync(JsonElement payload) if (!string.IsNullOrWhiteSpace(error) && (_nativeSession is not null || !error.Contains("this.prompt is not a function", StringComparison.OrdinalIgnoreCase))) { - ShowError(error); - return; + var decision = SetupWizardRunner.DecideTerminalWizardError( + payloadIsTerminal: true, + error, + _finalStepTracker.AnsweredFinalStep); + if (!decision.MarksWizardCompleted) + { + ShowError(error); + return; + } } _nativeSession?.MarkWizardCompleted(); @@ -349,11 +362,14 @@ private async Task ApplyPayloadAsync(JsonElement payload) 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; + _stepIndex = stepIndex; + _totalSteps = payload.TryGetProperty("totalSteps", out var totalProperty) && totalProperty.TryGetInt32(out var total) ? total : 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); + _stepHasOptions = hasOptions; _stepCategory = WizardStepClassifier.Categorize(_stepType, hasOptions); if (_stepCategory == WizardStepCategory.RequiresAnswer @@ -400,6 +416,7 @@ private async Task ApplyPayloadAsync(JsonElement payload) if (generation != _operationGeneration || _errorState || _client == null) return; + _finalStepTracker.RecordProgressAcknowledgement(); payload = await SendWizardRequestAsync(connection, generation, "wizard.next", WizardNextPayload.Acknowledge(_sessionId, _stepId), @@ -789,6 +806,13 @@ private async Task SendOptionValueAsync(string value) SetBusy("Loading..."); ClearConsoleBanner(); var parameters = new { sessionId = _sessionId, answer = new { stepId = _stepId, value } }; + _finalStepTracker.RecordAnsweredStep( + _stepType, + _stepId, + _currentTitle, + _stepHasOptions, + _stepIndex, + _totalSteps); var payload = await SendWizardRequestAsync(connection, generation, "wizard.next", parameters, timeoutMs: TimeoutForCurrentStep()); if (generation != _operationGeneration) return; await ApplyPayloadAsync(payload); @@ -810,6 +834,13 @@ private async Task ExpandMoreOptionsAsync(string moreValue, List= 0, $"Could not find marker after index {current}: {marker}"); + current = next; + } + } + + private static string ExtractMethod(string source, string methodName) + { + var signature = source.IndexOf($"async Task {methodName}(", StringComparison.Ordinal); + Assert.True(signature >= 0, $"Could not find method {methodName}."); + var brace = source.IndexOf('{', signature); + Assert.True(brace >= 0, $"Could not find body for method {methodName}."); + + var depth = 0; + for (var index = brace; index < source.Length; index++) + { + if (source[index] == '{') + depth++; + else if (source[index] == '}') + { + depth--; + if (depth == 0) + return source.Substring(signature, index - signature + 1); + } + } + + throw new InvalidOperationException($"Could not extract method {methodName}."); + } + + private static string WizardPageSource() => + File.ReadAllText( + Path.Combine( + RepositoryRoot(), + "src", + "OpenClaw.SetupEngine.UI", + "Pages", + "WizardPage.xaml.cs")); + private static int CountOccurrences(string source, string value) { var count = 0; diff --git a/tests/OpenClaw.Tray.UITests/OnboardingWindowsFlowTests.cs b/tests/OpenClaw.Tray.UITests/OnboardingWindowsFlowTests.cs index 2e531872f..59a9512aa 100644 --- a/tests/OpenClaw.Tray.UITests/OnboardingWindowsFlowTests.cs +++ b/tests/OpenClaw.Tray.UITests/OnboardingWindowsFlowTests.cs @@ -855,6 +855,152 @@ await InvokeCardAsync(window, choices, Assert.All(host.Checks.Concat(host.Connects), request => Assert.Equal("wss://mounted-flow.invalid", request.GatewayUrl)); } + [Fact] + public async Task LegacyWizard_FinalSigtermCompletes_EarlierSigtermStaysAnError() + { + const string token = "sigterm-fixture-token"; + var mode = "early"; + await using var server = await FixtureGatewayServer.StartAsync( + GatewayScenario.CreateNativeSetup((method, _) => + { + if (method == "wizard.start") + return mode == "final" ? FinalStep() : EarlyStep(); + if (method == "wizard.next") + return new + { + sessionId = "sigterm-session", + done = true, + error = GatewayWizardRestartRecoveryPolicy.HostedWizardTerminationError, + }; + if (method == "logs.tail") + return new { file = "fixture.log", cursor = 0, size = 0, lines = Array.Empty() }; + return new { ok = true }; + }), + token); + + await RunAsync("early"); + mode = "final"; + await RunAsync("final"); + + async Task RunAsync(string phase) + { + var seen = server.Requests.Count; + await WithWindowAsync(async (window, frame, data) => + { + var registry = new GatewayRegistry(data); + var record = registry.AddOrUpdate(new() + { + Id = "sigterm-fixture", + Url = server.Endpoint.ToString(), + FriendlyName = "SIGTERM fixture", + SharedGatewayToken = token, + IsLocal = false, + }); + registry.SetActive(record.Id); + registry.Save(); + // Existing leaves the WSL workspace untouched. The visible result is the + // completion page, not a Windows node guidance install. + window.SelectGatewayRoute(SetupGatewayRoute.Existing); + Assert.Equal(SetupGatewayRoute.Existing, window.AccessDraft.Route); + Assert.False(OnboardingFlowPolicy.UsesWslWorkspaceFinalization(window.AccessDraft.Route)); + Assert.True(window.TryNavigateToLegacyWizard()); + var wizard = await MountedPageAsync(window, frame); + if (phase == "early") + await AssertEarlyAsync(wizard, frame); + else + await AssertFinalAsync(frame); + var methods = string.Join(",", server.Requests.Skip(seen).Select(request => request.Method)); + output.WriteLine($"SIGTERM-{phase} requests={methods}"); + Assert.Contains(server.Requests.Skip(seen), request => request.Method == "wizard.start"); + Assert.Contains(server.Requests.Skip(seen), request => request.Method == "wizard.next"); + }); + } + + async Task AssertEarlyAsync(WizardPage wizard, Frame frame) + { + await WaitRendered(frame, () => + Find(wizard, "TitleText").Text == "Channel" && + Find