Skip to content
Draft
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
8 changes: 5 additions & 3 deletions src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -496,12 +496,14 @@ private async Task NavigateWhenChatReadyAsync(
}

WaitingStatusText.Text = LocalizationHelper.GetString("ChatPage_ChatReady");
var app = (App)Application.Current;
var bootstrapped = await OnboardingChatBootstrapper.BootstrapAsync(
connectionManager?.OperatorClient,
((App)Application.Current).Settings,
app.Settings,
TimeSpan.FromSeconds(90),
cancellationToken).ConfigureAwait(true);
if (!bootstrapped && !((App)Application.Current).Settings.HasInjectedFirstRunBootstrap)
cancellationToken,
registry: app.Registry).ConfigureAwait(true);
if (!bootstrapped && !app.Settings.HasInjectedFirstRunBootstrap)
{
Logger.Warn("[ChatPage] Gateway hatching bootstrap did not complete; navigating to empty chat");
}
Expand Down
18 changes: 17 additions & 1 deletion src/OpenClaw.Tray.WinUI/Services/OnboardingChatBootstrapper.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using OpenClaw.Connection;
using OpenClaw.Shared;
using System;
using System.Collections.Generic;
Expand Down Expand Up @@ -36,12 +37,27 @@ public static async Task<bool> BootstrapAsync(
IOperatorGatewayClient? client,
SettingsManager settings,
TimeSpan? completionTimeout = null,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
GatewayRegistry? registry = null)
{
ArgumentNullException.ThrowIfNull(settings);

if (settings.HasInjectedFirstRunBootstrap)
return true;

// Guard: if the user already has a configured gateway, treat this as a non-first-run
// installation and silently consume the bootstrap gate without sending the prompt.
// This prevents the first-run ritual from firing against an already-configured workspace
// in cases where the HasInjectedFirstRunBootstrap flag was not persisted (e.g. fresh
// app install over an existing workspace, settings migration, or flag reset).
if (registry is not null && SetupExistingGatewayClassifier.HasAnyExistingGatewayConnection(
registry, settings, SettingsManager.SettingsDirectoryPath))
{
MarkBootstrapped(settings);
Logger.Info("[OnboardingChatBootstrapper] Existing gateway configuration detected; skipping first-run bootstrap prompt.");
return true;
}

if (client == null || !client.IsConnectedToGateway)
return false;
if (Interlocked.CompareExchange(ref s_inFlight, 1, 0) != 0)
Expand Down
87 changes: 87 additions & 0 deletions tests/OpenClaw.Tray.Tests/OnboardingChatBootstrapperTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using OpenClaw.Connection;
using OpenClaw.Shared;
using OpenClawTray.Services;
using System.Text.Json;
Expand Down Expand Up @@ -82,6 +83,92 @@ public async Task BootstrapAsync_DoesNotConsumeGate_WhenCompletionTimesOut()
Assert.False(settings.HasInjectedFirstRunBootstrap);
}

[Fact]
public async Task BootstrapAsync_SkipsPromptAndMarksBootstrapped_WhenRegistryHasExistingGatewayWithSharedToken()
{
var settings = new SettingsManager(_settingsDir);
var client = new FakeOperatorGatewayClient { IsConnectedToGateway = true };

var registryDir = Path.Combine(_settingsDir, "registry-existing");
Directory.CreateDirectory(registryDir);
var registry = new GatewayRegistry(registryDir);
registry.AddOrUpdate(new GatewayRecord
{
Id = "gw-existing",
Url = "ws://192.168.1.10:18789",
SharedGatewayToken = "existing-shared-token"
});

var result = await OnboardingChatBootstrapper.BootstrapAsync(client, settings, TimeSpan.FromSeconds(5), registry: registry);

Assert.True(result, "Should return true when existing gateway is detected.");
Assert.Equal(0, client.SendCount);
Assert.True(settings.HasInjectedFirstRunBootstrap, "Gate should be marked so the check doesn't repeat.");
}

[Fact]
public async Task BootstrapAsync_SkipsPromptAndMarksBootstrapped_WhenRegistryHasExistingGatewayWithBootstrapToken()
{
var settings = new SettingsManager(_settingsDir);
var client = new FakeOperatorGatewayClient { IsConnectedToGateway = true };

var registryDir = Path.Combine(_settingsDir, "registry-bootstrap");
Directory.CreateDirectory(registryDir);
var registry = new GatewayRegistry(registryDir);
registry.AddOrUpdate(new GatewayRecord
{
Id = "gw-bootstrap",
Url = "ws://my-gateway:18789",
BootstrapToken = "existing-bootstrap-token"
});

var result = await OnboardingChatBootstrapper.BootstrapAsync(client, settings, TimeSpan.FromSeconds(5), registry: registry);

Assert.True(result);
Assert.Equal(0, client.SendCount);
Assert.True(settings.HasInjectedFirstRunBootstrap);
}

[Fact]
public async Task BootstrapAsync_SendsBootstrapPrompt_WhenRegistryIsEmptyAndGatewayIsNew()
{
var settings = new SettingsManager(_settingsDir);
var client = new FakeOperatorGatewayClient { Result = new ChatSendResult { RunId = "run-new" } };

var registryDir = Path.Combine(_settingsDir, "registry-empty");
Directory.CreateDirectory(registryDir);
var registry = new GatewayRegistry(registryDir);
// Registry has no records β€” this is a true first-run scenario.

var task = OnboardingChatBootstrapper.BootstrapAsync(client, settings, TimeSpan.FromSeconds(5), registry: registry);
// slopwatch-ignore: SW004 Test delay is an intentional bounded async wait; replacing it would change the scenario under test.
await Task.Delay(50);
client.RaiseFinalAssistant("run-new");
var result = await task;

Assert.True(result);
Assert.Equal(1, client.SendCount);
Assert.Equal(OnboardingChatBootstrapper.Message, client.LastMessage);
Assert.True(settings.HasInjectedFirstRunBootstrap);
}

[Fact]
public async Task BootstrapAsync_SendsBootstrapPrompt_WhenNoRegistryProvided()
{
var settings = new SettingsManager(_settingsDir);
var client = new FakeOperatorGatewayClient { Result = new ChatSendResult { RunId = "run-noregistry" } };

var task = OnboardingChatBootstrapper.BootstrapAsync(client, settings, TimeSpan.FromSeconds(5));
// slopwatch-ignore: SW004 Test delay is an intentional bounded async wait; replacing it would change the scenario under test.
await Task.Delay(50);
client.RaiseFinalAssistant("run-noregistry");
var result = await task;

Assert.True(result);
Assert.Equal(1, client.SendCount);
Assert.True(settings.HasInjectedFirstRunBootstrap);
}

#pragma warning disable CS0067
private sealed class FakeOperatorGatewayClient : IOperatorGatewayClient
{
Expand Down