fix(macos): reopen AI choice, fix daemon error text, hand onboarding off to the dashboard custodian (#117921)

* fix(macos): let onboarding replace an auto-connected AI

The AI page auto-tests the best detected candidate and connects without
asking, then hides every alternative route. Add 'Choose a different AI'
to the connected banner: a re-detect pass with auto-activation
suppressed that ends at the picker (candidates, provider sign-in, API
keys). Also disable the manual key Connect button while another test
runs (submitManualKey silently dropped the tap), and isolate a test
that read the machine's real resume store.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(macos): surface real daemon errors past the Node banner

Gateway daemon failures summarized as 'Node.js v26.5.1' because the
summary takes the last non-empty line and Node fatal errors end with a
version banner. Drop trailing banner lines and prefer the last
error-shaped line above them; all other output keeps its last line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(macos): hand onboarding off to the dashboard custodian

Native onboarding now ends once inference verifies: welcome, connection,
install (when needed), AI setup. Finish opens the dashboard at
/custodian?onboarding=1, where the custodian onboarding owns memory
import, channels, app recommendations, and the hatch (browser-first per
the onboarding redesign). The native memory-import and permissions
pages leave the first-run flow; 'Set up later' keeps the native ready
page. The native navigation bridge gains a validated optional search
field so the handoff can request onboarding chrome; the URL fallback
carries the query alongside the token fragment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(macos): delete the unreachable native memory-import module

The dashboard handoff removed the memory-import page from every flow,
leaving the module reachable only from tests. CI's dead-code scan
rightly flagged the first orphans; remove the whole path (model, page,
mascot wiring, tests) instead of trimming symbol by symbol. The
dashboard's own memory-import surface owns the feature.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Peter Steinberger <steipete@mac-studio-sf2.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Peter Steinberger
2026-08-02 02:57:25 -07:00
committed by GitHub
parent 85a85515ef
commit 76cb418b6f
29 changed files with 537 additions and 2501 deletions
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,5 @@
import AppKit
import OpenClawKit
@MainActor
enum AppNavigationActions {
@@ -6,6 +7,17 @@ enum AppNavigationActions {
DashboardManager.shared.presentDashboard()
}
/// Post-AI-setup handoff: land in the dashboard's custodian onboarding,
/// which owns everything after working inference (memory import, channels,
/// app recommendations, hatch).
static func openDashboardOnboarding() {
Task { @MainActor in
await DashboardManager.shared.show(
atPath: DashboardRouteMap.custodianPagePath,
search: DashboardRouteMap.custodianOnboardingSearch)
}
}
static func openChat(sessionKey: String? = nil, agentID: String? = nil, draft: String? = nil) {
NSApp.activate(ignoringOtherApps: true)
Task { @MainActor in
@@ -424,7 +424,7 @@ final class DashboardManager {
Task { _ = try? await ControlChannel.shared.health(timeout: 3) }
}
func show(atPath path: String) async {
func show(atPath path: String, search: String? = nil) async {
self.navigationGeneration &+= 1
let generation = self.navigationGeneration
do {
@@ -433,9 +433,13 @@ final class DashboardManager {
guard let controller,
let fallbackURL = DashboardRouteMap.dashboardURL(
byAppendingSameAppPath: path,
search: search,
to: controller.dashboardBaseURL)
else { return }
controller.dispatchNativeNavigation(DashboardNativeNavigation(path: path, fallbackURL: fallbackURL))
controller.dispatchNativeNavigation(DashboardNativeNavigation(
path: path,
search: search,
fallbackURL: fallbackURL))
} catch {
guard generation == self.navigationGeneration else { return }
self.showFailure(error)
@@ -63,6 +63,7 @@ enum DashboardNativeCommand: String {
struct DashboardNativeNavigation: Equatable {
let path: String
var search: String?
let fallbackURL: URL
}
@@ -1221,11 +1221,12 @@ extension DashboardWindowController {
private func evaluateNativeNavigation(_ navigation: DashboardNativeNavigation) {
let generation = self.navigationGeneration
let sourceURL = self.currentURL
let searchLiteral = navigation.search.map(Self.jsStringLiteral) ?? "undefined"
let script =
"""
(() => !window.dispatchEvent(new CustomEvent('openclaw:native-navigate', {
cancelable: true,
detail: {path: \(Self.jsStringLiteral(navigation.path))}
detail: {path: \(Self.jsStringLiteral(navigation.path)), search: \(searchLiteral)}
})))()
"""
Task { @MainActor [weak self] in
+13 -45
View File
@@ -625,7 +625,6 @@ struct OnboardingView: View {
@State var onboardingSkillsModel = SkillsSettingsModel()
@State var systemAgentState = OnboardingSystemAgentChatState()
@State var aiSetup = OnboardingAISetupModel()
@State var memoryImport = OnboardingMemoryImportModel()
@State var configuredGatewayProbe = OnboardingConfiguredGatewayProbe()
@State var didLoadOnboardingSkills = false
@State var localGatewayProbe: LocalGatewayProbe?
@@ -635,7 +634,6 @@ struct OnboardingView: View {
let systemAgentDefaults: UserDefaults
let aiSetupRouteIdentityProvider: @MainActor () -> String?
let gatewaySelectionPersister: @MainActor () -> Bool
let memoryImportGateway: GatewayConnection
static let windowWidth: CGFloat = 630
static let windowHeight: CGFloat = 752 // ~+10% to fit full onboarding content
@@ -645,7 +643,6 @@ struct OnboardingView: View {
let connectionPageIndex = 1
let cliPageIndex = 2
let aiPageIndex = 3
let memoryImportPageIndex = 4
let onboardingChatPageIndex = 8
let readyPageIndex = 9
@@ -672,45 +669,22 @@ struct OnboardingView: View {
static func pageOrder(
for mode: AppState.ConnectionMode,
requiresCLIInstall: Bool,
memoryImportEligible: Bool = false) -> [Int]
requiresCLIInstall: Bool) -> [Int]
{
switch mode {
case .remote:
// Remote mode skips local Gateway/workspace setup, but its Mac node
// still runs the matching CLI node-host runtime inside the app.
let setupPages = requiresCLIInstall ? [0, 1, 2, 3, 5] : [0, 1, 3, 5]
return setupPages + [9]
case .remote, .local:
// Native onboarding ends once inference works: install (when
// needed) plus AI setup. Everything after memory import,
// permissions, channels, hatch belongs to the dashboard's
// custodian onboarding, which Finish opens.
requiresCLIInstall ? [0, 1, 2, 3] : [0, 1, 3]
case .unconfigured:
return [0, 1, 9]
case .local:
let memoryPages = memoryImportEligible ? [4] : []
let setupPages = (requiresCLIInstall ? [0, 1, 2, 3] : [0, 1, 3]) + memoryPages + [5]
return setupPages + [9]
// "Set up later" has no gateway to hand off to; keep the native
// ready page so the flow still ends with a visible outcome.
[0, 1, 9]
}
}
static func shouldIncludeMemoryImportPage(
for mode: AppState.ConnectionMode,
modelEligible: Bool) -> Bool
{
mode == .local && modelEligible
}
static func reconciledPageCursor(
currentPage: Int,
previousOrder: [Int],
newOrder: [Int]) -> Int
{
guard !newOrder.isEmpty else { return 0 }
guard !previousOrder.isEmpty else { return min(max(0, currentPage), newOrder.count - 1) }
let previousCursor = min(max(0, currentPage), previousOrder.count - 1)
let previousPage = previousOrder[previousCursor]
if let exact = newOrder.firstIndex(of: previousPage) { return exact }
if let next = newOrder.firstIndex(where: { $0 > previousPage }) { return next }
return newOrder.count - 1
}
static func shouldActivateLocalGateway(afterCLIInstallFor mode: AppState.ConnectionMode) -> Bool {
mode == .local
}
@@ -727,14 +701,9 @@ struct OnboardingView: View {
}
var pageOrder: [Int] {
let requiresCLIInstall = !self.cliInstalled
let includeMemoryImport = Self.shouldIncludeMemoryImportPage(
Self.pageOrder(
for: self.state.connectionMode,
modelEligible: self.memoryImport.pageEligible)
return Self.pageOrder(
for: self.state.connectionMode,
requiresCLIInstall: requiresCLIInstall,
memoryImportEligible: includeMemoryImport)
requiresCLIInstall: !self.cliInstalled)
}
var pageCount: Int {
@@ -783,7 +752,7 @@ struct OnboardingView: View {
}
var canAdvance: Bool {
!self.isCLIBlocking && !self.isAISetupBlocking && !self.memoryImport.isApplying
!self.isCLIBlocking && !self.isAISetupBlocking
}
struct LocalGatewayProbe: Equatable {
@@ -815,7 +784,6 @@ struct OnboardingView: View {
self.gatewaySelectionPersister = gatewaySelectionPersister ?? {
state.syncGatewayConfigNow()
}
self.memoryImportGateway = aiSetupGateway
_defaultsToLocalGateway = State(
initialValue: !state.onboardingSeen && state.connectionMode == .unconfigured)
_gatewayDiscovery = State(initialValue: discoveryModel)
@@ -124,6 +124,10 @@ final class OnboardingAISetupModel {
private let routeIdentityProvider: @MainActor () -> String?
private var started = false
private var attemptToken = UUID()
/// One-shot: the next detection pass lists choices without auto-activating,
/// so the connected-state "choose a different AI" path ends at a picker
/// instead of re-connecting the same auto candidate.
@ObservationIgnored private var suppressNextAutoActivation = false
@ObservationIgnored private var pendingVerification: PendingVerification?
@ObservationIgnored private var pendingActivationOwner: OnboardingSystemAgentResumeStore.ActivationOwner?
@ObservationIgnored private var completedHandoff: CompletedHandoff?
@@ -135,7 +139,9 @@ final class OnboardingAISetupModel {
/// Only a just-completed provider flow may trust setupComplete without re-probing.
@ObservationIgnored private var providerAuthReconciliationPending = false
private struct PersistedActivationState: Equatable {
/// Internal (not private): the persisted-transition helper lives in
/// OnboardingAISetupSupport.swift with the other pure statics.
struct PersistedActivationState: Equatable {
let setupComplete: Bool
let configuredModel: String?
}
@@ -231,6 +237,25 @@ final class OnboardingAISetupModel {
scheduleDetection()
}
/// Escape hatch from a successful auto-connect: re-detect and present every
/// candidate, provider, and API-key route without auto-activating, so the
/// user can replace the auto-chosen AI with one they pick themselves.
func chooseDifferentAI() {
guard self.beginChooseDifferentAI() else { return }
self.scheduleDetection()
}
/// Split from `chooseDifferentAI` so tests can drive the detection await.
@discardableResult
func beginChooseDifferentAI() -> Bool {
guard self.connected else { return false }
self.resetForGatewayChange()
self.suppressNextAutoActivation = true
self.started = true
self.phase = .detecting
return true
}
func showConfiguredGatewayProbeUnavailable() {
guard !self.ownsInferenceTransition ||
self.configuredGatewayProbeUnavailable ||
@@ -658,6 +683,7 @@ final class OnboardingAISetupModel {
self.manualError = nil
self.manualTesting = false
self.showManualEntry = false
self.suppressNextAutoActivation = false
if let authSessionToCancel, let authServerLease {
Task {
await self.gateway.cancelWizardSession(authSessionToCancel, on: authServerLease)
@@ -775,6 +801,13 @@ extension OnboardingAISetupModel {
self.statuses[candidate.kind] = .untried
}
self.phase = .ready
if self.suppressNextAutoActivation {
// "Choose a different AI" pass: list every route and let the
// user pick; auto-activating here would redo the undone choice.
self.suppressNextAutoActivation = false
self.showManualEntry = !self.manualProviders.isEmpty
return
}
if let preparedChoiceID {
// Detection kinds encode the provider-auth choice ID, while
// PrepareOption.brandId owns the model-ref namespace.
@@ -794,14 +827,15 @@ extension OnboardingAISetupModel {
return
}
if let first = autoCandidateAfter(kind: nil) {
// Candidate found: connect without asking. Switching later
// stays one click away while the test runs server-side.
// Candidate found: connect without asking. The connected banner
// keeps "Choose a different AI" so this choice stays reversible.
await self.activate(kind: first.kind, context: context)
} else {
self.showManualEntry = !self.manualProviders.isEmpty
}
} catch {
guard self.isCurrentAttempt(context) else { return }
self.suppressNextAutoActivation = false
self.phase = .ready
self.detectError = Self.transportFailure(error.localizedDescription)
self.showManualEntry = self.candidates.isEmpty
@@ -841,16 +875,6 @@ extension OnboardingAISetupModel {
"No Gateway is selected. Select a Gateway, then try again.")
}
private static func activationTransitionWasPersisted(
expectedModel: String,
before: PersistedActivationState?,
after: PersistedActivationState?) -> Bool
{
guard let before, let after else { return false }
let wasAlreadyPersisted = before.setupComplete && before.configuredModel == expectedModel
return !wasAlreadyPersisted && after.setupComplete && after.configuredModel == expectedModel
}
/// Candidates the automatic ladder may try: skip definitively logged-out
/// installs and anything already attempted.
private func autoCandidateAfter(kind: String?) -> Candidate? {
@@ -1123,16 +1147,6 @@ extension OnboardingAISetupModel {
activationOwner: activationOwner)
return self.connected
}
private static func remainingMilliseconds(
until deadline: ContinuousClock.Instant,
clock: ContinuousClock,
cappedAt capMs: Int) -> Int
{
let components = clock.now.duration(to: deadline).components
let milliseconds = components.seconds * 1000 + components.attoseconds / 1_000_000_000_000_000
return max(0, min(capMs, Int(milliseconds)))
}
}
extension OnboardingAISetupModel {
@@ -261,4 +261,24 @@ extension OnboardingAISetupModel {
var connectedSetupCopyText: String {
connectedSetupLines.joined(separator: "\n")
}
static func activationTransitionWasPersisted(
expectedModel: String,
before: PersistedActivationState?,
after: PersistedActivationState?) -> Bool
{
guard let before, let after else { return false }
let wasAlreadyPersisted = before.setupComplete && before.configuredModel == expectedModel
return !wasAlreadyPersisted && after.setupComplete && after.configuredModel == expectedModel
}
static func remainingMilliseconds(
until deadline: ContinuousClock.Instant,
clock: ContinuousClock,
cappedAt capMs: Int) -> Int
{
let components = clock.now.duration(to: deadline).components
let milliseconds = components.seconds * 1000 + components.attoseconds / 1_000_000_000_000_000
return max(0, min(capMs, Int(milliseconds)))
}
}
@@ -322,6 +322,14 @@ struct OnboardingAISetupView: View {
.buttonStyle(.link)
.font(.caption)
}
Button {
self.model.chooseDifferentAI()
} label: {
Label("Choose a different AI…", systemImage: "arrow.triangle.2.circlepath")
}
.buttonStyle(.link)
.font(.caption)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -858,7 +866,10 @@ struct OnboardingAISetupView: View {
}
}
.buttonStyle(.borderedProminent)
.disabled(self.model.manualTesting ||
// isBusy, not just manualTesting: submitManualKey drops the tap
// while another test runs, so an enabled button would be a
// silent no-op.
.disabled(self.model.isBusy ||
self.model.manualKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
Text(self.manualProviderHelp)
@@ -1,728 +0,0 @@
import Foundation
import Observation
import OpenClawKit
import OpenClawProtocol
@MainActor
@Observable
final class OnboardingMemoryImportModel {
struct Provider: Identifiable, Equatable {
let providerId: String
let label: String
let source: String?
let found: Bool
let plannedItemIds: [String]
let alreadyImportedCount: Int
let planFingerprint: String?
var selected: Bool
var inlineError: String?
var result: ProviderResult?
var requiresReplan: Bool
var appliedPlanFingerprint: String?
var id: String {
self.providerId
}
var plannedCount: Int {
self.plannedItemIds.count
}
var isActionable: Bool {
self.found && !self.requiresReplan && self.plannedCount > 0 &&
self.appliedPlanFingerprint != self.planFingerprint &&
self.planFingerprint?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
}
}
struct ProviderResult: Identifiable, Equatable {
let providerId: String
let label: String
let migrated: Int
let skipped: Int
let conflicts: Int
let errors: Int
var id: String {
self.providerId
}
}
enum Phase: Equatable {
case idle
case planning
case offer([Provider])
case empty
case failed(String)
case applying
case done([ProviderResult])
}
private(set) var phase: Phase = .idle
private(set) var autoAdvanceRequested = false
private(set) var applyingProviders: [Provider] = []
@ObservationIgnored private var operationToken = UUID()
@ObservationIgnored private var agentId: String?
@ObservationIgnored private var planningLease: GatewayConnection.ServerLease?
@ObservationIgnored private var applyIdempotencyKeys: [String: String] = [:]
@ObservationIgnored private var replanCarryover: [String: Provider] = [:]
@ObservationIgnored private var replanSourceLease: GatewayConnection.ServerLease?
@ObservationIgnored private var replanSourceAgentId: String?
@ObservationIgnored private var replanSourceWorkspace: String?
@ObservationIgnored private var planWorkspace: String?
@ObservationIgnored private var pageIsActive = false
private var failureDismissed = false
var providers: [Provider] {
switch self.phase {
case let .offer(providers): providers
case .applying: self.applyingProviders
default: []
}
}
var results: [ProviderResult] {
if case let .done(results) = self.phase { return results }
return []
}
var hasOffer: Bool {
self.providers.contains { $0.found && $0.isActionable }
}
var resolvedEmpty: Bool {
if case .empty = self.phase { return true }
return false
}
var isApplying: Bool {
if case .applying = self.phase { return true }
return false
}
var isFailed: Bool {
if case .failed = self.phase { return true }
return false
}
/// Empty results stay in the pager only long enough for an active page to
/// hand off. A dismissed failure is likewise removed by the navigation owner.
var pageEligible: Bool {
switch self.phase {
case .empty:
self.autoAdvanceRequested
case .failed:
!self.failureDismissed
case .idle, .planning, .offer, .applying, .done:
true
}
}
var hasSelectedProviders: Bool {
self.providers.contains { $0.selected && $0.isActionable }
}
var hasReplanRequired: Bool {
self.providers.contains(where: \.requiresReplan)
}
var canReplan: Bool {
self.hasReplanRequired && self.applyIdempotencyKeys.isEmpty
}
var shouldStartAutomatically: Bool {
if case .idle = self.phase { return true }
return false
}
func setPageActive(_ active: Bool) {
self.pageIsActive = active
}
func setSelected(_ selected: Bool, providerId: String) {
guard case var .offer(providers) = self.phase,
let index = providers.firstIndex(where: { $0.providerId == providerId }),
providers[index].isActionable
else { return }
providers[index].selected = selected
providers[index].inlineError = nil
self.phase = .offer(providers)
}
func consumeAutoAdvanceRequest() {
self.autoAdvanceRequested = false
self.pageIsActive = false
}
func dismissFailure() {
guard self.isFailed else { return }
self.failureDismissed = true
self.pageIsActive = false
}
func reset() {
self.operationToken = UUID()
self.agentId = nil
self.planningLease = nil
self.applyIdempotencyKeys = [:]
self.replanCarryover = [:]
self.replanSourceLease = nil
self.replanSourceAgentId = nil
self.replanSourceWorkspace = nil
self.planWorkspace = nil
self.phase = .idle
self.applyingProviders = []
self.autoAdvanceRequested = false
self.pageIsActive = false
self.failureDismissed = false
}
/// Resolve the default agent on the same physical Gateway connection used
/// for planning, so a reconnect cannot pair one server's agent with another.
func startPlanning(gateway: GatewayConnection) async {
guard let token = self.beginPlanning() else { return }
var lease: GatewayConnection.ServerLease?
do {
let (acquiredLease, preservedSourceRoute) = try await self.acquirePlanningLease(gateway: gateway)
lease = acquiredLease
let agentId = try await gateway.defaultAgentId(ifCurrentServerLease: acquiredLease)
guard self.isCurrent(token) else { return }
guard await gateway.isCurrentServerLease(acquiredLease) else {
self.finishStaleLease(token: token)
return
}
guard self.isCurrent(token) else { return }
await self.discardReplanCarryoverUnlessSameTarget(
agentId: agentId,
preservedSourceRoute: preservedSourceRoute)
guard self.isCurrent(token) else { return }
self.planningLease = acquiredLease
await self.performPlanning(
gateway: gateway,
agentId: agentId,
lease: acquiredLease,
token: token)
} catch {
if let lease, await !(gateway.isCurrentServerLease(lease)) {
self.finishStaleLease(token: token)
return
}
self.finishPlanningFailure(error.localizedDescription, token: token)
}
}
func startPlanning(gateway: GatewayConnection, agentId: String) async {
guard let token = self.beginPlanning() else { return }
var lease: GatewayConnection.ServerLease?
do {
let (acquiredLease, preservedSourceRoute) = try await self.acquirePlanningLease(gateway: gateway)
lease = acquiredLease
guard self.isCurrent(token) else { return }
guard await gateway.isCurrentServerLease(acquiredLease) else {
self.finishStaleLease(token: token)
return
}
guard self.isCurrent(token) else { return }
await self.discardReplanCarryoverUnlessSameTarget(
agentId: agentId,
preservedSourceRoute: preservedSourceRoute)
guard self.isCurrent(token) else { return }
self.planningLease = acquiredLease
await self.performPlanning(
gateway: gateway,
agentId: agentId,
lease: acquiredLease,
token: token)
} catch {
if let lease, await !(gateway.isCurrentServerLease(lease)) {
self.finishStaleLease(token: token)
return
}
self.finishPlanningFailure(error.localizedDescription, token: token)
}
}
func importSelected(gateway: GatewayConnection) async {
guard case let .offer(offeredProviders) = self.phase,
let agentId = self.agentId,
let lease = self.planningLease
else { return }
let selectedIds = Set(offeredProviders.compactMap { provider in
provider.selected && provider.isActionable
? provider.providerId
: nil
})
guard !selectedIds.isEmpty else { return }
let token = UUID()
self.operationToken = token
self.applyingProviders = offeredProviders
self.phase = .applying
guard await gateway.isCurrentServerLease(lease) else {
self.finishStaleLease(token: token)
return
}
guard self.isCurrent(token) else { return }
var providers = self.applyingProviders
for index in providers.indices where selectedIds.contains(providers[index].providerId) {
let outcome = await self.applyOne(
at: index,
providers: &providers,
gateway: gateway,
agentId: agentId,
lease: lease,
token: token)
guard outcome == .continueBatch else { return }
}
guard self.isCurrent(token) else { return }
guard await gateway.isCurrentServerLease(lease) else {
self.finishStaleLease(token: token, applyingProviders: providers)
return
}
guard self.isCurrent(token) else { return }
let results = providers.compactMap(\.result)
self.applyingProviders = []
if providers.contains(where: {
$0.inlineError != nil || (($0.result?.errors ?? 0) + ($0.result?.conflicts ?? 0)) > 0
}) {
self.phase = .offer(providers)
} else {
self.planningLease = nil
self.applyIdempotencyKeys = [:]
self.phase = .done(results)
}
}
private enum ApplyBatchOutcome {
case continueBatch
case abort
}
/// Applies one provider's planned items; mutates its row in place. `.abort`
/// means the operation token or server lease went stale mid-flight.
private func applyOne(
at index: Int,
providers: inout [Provider],
gateway: GatewayConnection,
agentId: String,
lease: GatewayConnection.ServerLease,
token: UUID) async -> ApplyBatchOutcome
{
guard self.isCurrent(token) else { return .abort }
guard await gateway.isCurrentServerLease(lease) else {
self.finishStaleLease(token: token, applyingProviders: providers)
return .abort
}
guard self.isCurrent(token) else { return .abort }
guard let fingerprint = providers[index].planFingerprint else {
providers[index].inlineError = "The Gateway did not return a usable import plan. Try planning again."
return .continueBatch
}
let providerId = providers[index].providerId
let idempotencyKey = self.applyIdempotencyKeys[providerId] ?? UUID().uuidString
self.applyIdempotencyKeys[providerId] = idempotencyKey
do {
let data = try await gateway.request(
method: "migrations.memory.apply",
params: [
"idempotencyKey": AnyCodable(idempotencyKey),
"agentId": AnyCodable(agentId),
"providerId": AnyCodable(providerId),
"planFingerprint": AnyCodable(fingerprint),
"itemIds": AnyCodable(providers[index].plannedItemIds),
"overwrite": AnyCodable(false),
],
timeoutMs: 120_000,
ifCurrentServerLease: lease)
guard self.isCurrent(token) else { return .abort }
guard await gateway.isCurrentServerLease(lease) else {
self.finishStaleLease(token: token, applyingProviders: providers)
return .abort
}
guard self.isCurrent(token) else { return .abort }
let result = try JSONDecoder().decode(MigrationsMemoryApplyResult.self, from: data)
guard result.providerid == providerId else {
throw OnboardingMemoryImportError.unexpectedApplyProvider
}
self.applyIdempotencyKeys.removeValue(forKey: providerId)
Self.recordApplyResult(
&providers[index],
summary: result.summary,
fingerprint: fingerprint)
} catch {
guard self.isCurrent(token) else { return .abort }
guard await gateway.isCurrentServerLease(lease) else {
self.finishStaleLease(token: token, applyingProviders: providers)
return .abort
}
guard self.isCurrent(token) else { return .abort }
// A Gateway rejection is definitive. Transport and decode
// failures are ambiguous, so a retry must reuse the same key.
if error is GatewayResponseError {
self.applyIdempotencyKeys.removeValue(forKey: providerId)
providers[index].selected = false
providers[index].requiresReplan = true
}
providers[index].inlineError = error.localizedDescription
}
return .continueBatch
}
/// Conflicts mean selected items were skipped after planning (a target
/// appeared mid-apply); they need a replan, not "done".
private static func recordApplyResult(
_ provider: inout Provider,
summary: MemoryMigrationSummary,
fingerprint: String)
{
provider.selected = false
provider.result = self.mergeResult(
provider.result,
providerId: provider.providerId,
label: provider.label,
summary: summary)
let incomplete = summary.errors + summary.conflicts
provider.requiresReplan = incomplete > 0
provider.appliedPlanFingerprint = incomplete == 0 ? fingerprint : nil
provider.inlineError = incomplete > 0
? "\(incomplete) \(Self.memoryNoun(incomplete)) could not be imported."
: nil
}
private func beginPlanning() -> UUID? {
switch self.phase {
case .idle:
self.discardReplanCarryover()
case .failed:
break
case let .offer(providers):
guard self.canReplan else { return nil }
self.replanCarryover = Dictionary(uniqueKeysWithValues: providers.map { ($0.providerId, $0) })
self.replanSourceLease = self.planningLease
self.replanSourceAgentId = self.agentId
self.replanSourceWorkspace = self.planWorkspace
case .planning, .empty, .applying, .done:
return nil
}
let token = UUID()
self.operationToken = token
self.agentId = nil
self.planningLease = nil
self.planWorkspace = nil
if self.replanCarryover.isEmpty {
self.applyIdempotencyKeys = [:]
}
self.phase = .planning
self.applyingProviders = []
self.autoAdvanceRequested = false
self.failureDismissed = false
return token
}
private func performPlanning(
gateway: GatewayConnection,
agentId: String,
lease: GatewayConnection.ServerLease,
token: UUID) async
{
do {
let data = try await gateway.request(
method: "migrations.memory.plan",
params: [
"agentId": AnyCodable(agentId),
"overwrite": AnyCodable(false),
],
timeoutMs: 30000,
ifCurrentServerLease: lease)
guard self.isCurrent(token) else { return }
guard await gateway.isCurrentServerLease(lease) else {
self.finishStaleLease(token: token)
return
}
guard self.isCurrent(token) else { return }
let result = try JSONDecoder().decode(MigrationsMemoryPlanResult.self, from: data)
try Self.validatePlan(result, requestedAgentId: agentId)
if self.replanSourceWorkspace != nil,
self.replanSourceWorkspace != result.workspace
{
self.discardReplanCarryoverAndPendingKeys()
}
let providers = self.mergeReplanCarryover(into: result.providers.map(Self.provider(from:)))
self.agentId = agentId
self.planWorkspace = result.workspace
if let provider = providers.first(where: {
$0.requiresReplan && self.applyIdempotencyKeys[$0.providerId] != nil
}) {
self.finishPlanningFailure(
provider.inlineError ?? "The Gateway could not refresh a pending memory import. Try again.",
token: token)
} else if providers.contains(where: { $0.found && $0.isActionable }) {
self.planningLease = lease
self.discardReplanCarryover()
self.phase = .offer(providers)
} else if let provider = providers.first(where: { $0.inlineError != nil }),
let error = provider.inlineError
{
self.finishPlanningFailure("\(provider.label): \(error)", token: token)
} else if !providers.compactMap(\.result).isEmpty {
self.planningLease = nil
self.discardReplanCarryover()
self.phase = .done(providers.compactMap(\.result))
} else {
self.planningLease = nil
self.discardReplanCarryover()
self.phase = .empty
self.autoAdvanceRequested = self.pageIsActive
}
} catch {
guard self.isCurrent(token) else { return }
guard await gateway.isCurrentServerLease(lease) else {
self.finishStaleLease(token: token)
return
}
guard self.isCurrent(token) else { return }
self.finishPlanningFailure(error.localizedDescription, token: token)
}
}
private static func provider(from plan: MemoryMigrationProviderPlan) -> Provider {
let plannedItemIds = plan.items.compactMap { item in
item.status == .planned ? item.id : nil
}
let fingerprint = plan.planfingerprint?.trimmingCharacters(in: .whitespacesAndNewlines)
let usableFingerprint = fingerprint?.isEmpty == false ? fingerprint : nil
let inconsistentFoundError = !plan.found && !plannedItemIds.isEmpty
? "The Gateway returned an inconsistent provider plan. Try planning again."
: nil
let missingFingerprintError = !plannedItemIds.isEmpty && usableFingerprint == nil
? "The Gateway did not return a usable import plan. Try planning again."
: nil
let summaryError = plan.summary.errors > 0
? "The Gateway could not plan \(plan.summary.errors) \(Self.memoryNoun(plan.summary.errors)). Try again."
: nil
let planError = plan.error ?? inconsistentFoundError ?? missingFingerprintError ?? summaryError
return Provider(
providerId: plan.providerid,
label: plan.label,
source: plan.source,
found: plan.found,
plannedItemIds: plannedItemIds,
alreadyImportedCount: plan.items.count { $0.status == .conflict },
planFingerprint: usableFingerprint,
selected: plan.found && !plannedItemIds.isEmpty && usableFingerprint != nil && planError == nil,
inlineError: planError,
result: nil,
requiresReplan: planError != nil,
appliedPlanFingerprint: nil)
}
private func mergeReplanCarryover(into freshProviders: [Provider]) -> [Provider] {
guard !self.replanCarryover.isEmpty else { return freshProviders }
var merged = freshProviders.map { fresh -> Provider in
guard let previous = self.replanCarryover[fresh.providerId] else { return fresh }
let identityChanged = previous.planFingerprint != fresh.planFingerprint ||
previous.plannedItemIds != fresh.plannedItemIds
if identityChanged, !fresh.requiresReplan {
self.applyIdempotencyKeys.removeValue(forKey: fresh.providerId)
}
var provider = fresh
provider.result = previous.result
provider.appliedPlanFingerprint = identityChanged ? nil : previous.appliedPlanFingerprint
if previous.result?.errors == 0 {
provider.selected = !provider.requiresReplan && provider.isActionable
if !provider.requiresReplan {
provider.inlineError = nil
}
} else if previous.requiresReplan {
if !provider.requiresReplan {
provider.inlineError = nil
}
} else {
provider.selected = previous.selected && provider.isActionable
provider.inlineError = identityChanged
? fresh.inlineError
: (fresh.inlineError ?? previous.inlineError)
}
return provider
}
let mergedIds = Set(merged.map(\.providerId))
for providerId in Array(self.applyIdempotencyKeys.keys) where !mergedIds.contains(providerId) {
self.applyIdempotencyKeys.removeValue(forKey: providerId)
}
merged.append(contentsOf: self.replanCarryover.values.compactMap { previous in
guard previous.result != nil,
!mergedIds.contains(previous.providerId)
else { return nil }
var completed = previous
completed.selected = false
completed.inlineError = nil
completed.requiresReplan = false
completed.appliedPlanFingerprint = completed.planFingerprint
return completed
})
return merged
}
private func acquirePlanningLease(
gateway: GatewayConnection) async throws -> (GatewayConnection.ServerLease, Bool)
{
if let sourceLease = self.replanSourceLease {
let lease = try await gateway.acquireServerLease(
ifSameRouteAs: sourceLease,
timeoutMs: 15000)
return (lease, true)
}
return try await (gateway.acquireServerLease(), false)
}
private func discardReplanCarryoverUnlessSameTarget(
agentId: String,
preservedSourceRoute: Bool) async
{
guard !self.replanCarryover.isEmpty else { return }
guard self.replanSourceAgentId == agentId,
preservedSourceRoute
else {
self.discardReplanCarryoverAndPendingKeys()
return
}
}
private func discardReplanCarryover() {
self.replanCarryover = [:]
self.replanSourceLease = nil
self.replanSourceAgentId = nil
self.replanSourceWorkspace = nil
}
private func discardReplanCarryoverAndPendingKeys() {
self.discardReplanCarryover()
self.applyIdempotencyKeys = [:]
}
private static func mergeResult(
_ previous: ProviderResult?,
providerId: String,
label: String,
summary: MemoryMigrationSummary) -> ProviderResult
{
ProviderResult(
providerId: providerId,
label: label,
migrated: (previous?.migrated ?? 0) + summary.migrated,
skipped: (previous?.skipped ?? 0) + summary.skipped,
conflicts: (previous?.conflicts ?? 0) + summary.conflicts,
errors: summary.errors)
}
private static func validatePlan(
_ result: MigrationsMemoryPlanResult,
requestedAgentId: String) throws
{
guard result.agentid == requestedAgentId else {
throw OnboardingMemoryImportError.unexpectedPlanAgent
}
var providerIds = Set<String>()
for provider in result.providers {
let providerId = provider.providerid.trimmingCharacters(in: .whitespacesAndNewlines)
guard !providerId.isEmpty,
providerId == provider.providerid,
providerIds.insert(providerId).inserted
else {
throw OnboardingMemoryImportError.invalidProviderIdentity
}
}
}
private func finishPlanningFailure(_ message: String, token: UUID) {
guard self.isCurrent(token) else { return }
self.agentId = nil
self.planningLease = nil
self.planWorkspace = nil
if self.replanCarryover.isEmpty {
self.applyIdempotencyKeys = [:]
}
self.phase = .failed(message)
}
private func finishStaleLease(
token: UUID,
applyingProviders currentProviders: [Provider]? = nil)
{
guard self.isCurrent(token) else { return }
if let currentProviders {
self.applyingProviders = currentProviders
}
if !self.applyingProviders.isEmpty {
self.replanCarryover = Dictionary(uniqueKeysWithValues: self.applyingProviders.map {
($0.providerId, $0)
})
self.replanSourceLease = self.planningLease
self.replanSourceAgentId = self.agentId
self.replanSourceWorkspace = self.planWorkspace
} else if self.replanCarryover.isEmpty {
self.discardReplanCarryover()
self.applyIdempotencyKeys = [:]
} else if let planningLease = self.planningLease {
self.replanSourceLease = planningLease
}
self.agentId = nil
self.planningLease = nil
self.planWorkspace = nil
self.applyingProviders = []
self.phase = .failed("The Gateway reconnected while checking memories. Try again.")
}
private static func memoryNoun(_ count: Int) -> String {
count == 1 ? "memory" : "memories"
}
private func isCurrent(_ token: UUID) -> Bool {
self.operationToken == token && !Task.isCancelled
}
}
private struct DefaultAgentIdResult: Decodable {
let defaultId: String
}
extension GatewayConnection {
func defaultAgentId(ifCurrentServerLease lease: ServerLease) async throws -> String {
let data = try await self.request(
method: "agents.list",
params: [:],
timeoutMs: 15000,
ifCurrentServerLease: lease)
guard await self.isCurrentServerLease(lease) else { throw CancellationError() }
let result = try JSONDecoder().decode(DefaultAgentIdResult.self, from: data)
let id = result.defaultId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !id.isEmpty else {
throw OnboardingMemoryImportError.missingDefaultAgent
}
return id
}
}
private enum OnboardingMemoryImportError: LocalizedError {
case missingDefaultAgent
case unexpectedPlanAgent
case invalidProviderIdentity
case unexpectedApplyProvider
var errorDescription: String? {
switch self {
case .missingDefaultAgent:
"The Gateway did not report a default agent for memory import."
case .unexpectedPlanAgent:
"The Gateway returned a memory plan for a different agent. Try again."
case .invalidProviderIdentity:
"The Gateway returned an invalid memory provider plan. Try again."
case .unexpectedApplyProvider:
"The Gateway returned a result for a different memory provider. Try again."
}
}
}
@@ -86,13 +86,6 @@ extension OnboardingView {
func handleNext() {
// All callers (Next button, chat handoff) honor the same page gates.
guard canAdvance else { return }
if self.activePageIndex == self.memoryImportPageIndex,
self.memoryImport.isFailed
{
self.memoryImport.dismissFailure()
self.updateMonitoring(for: self.activePageIndex)
return
}
self.commitRecommendedConnectionIfNeeded(for: activePageIndex)
if currentPage < pageCount - 1 {
withAnimation { self.currentPage += 1 }
@@ -114,18 +107,15 @@ extension OnboardingView {
aiSetup.clearCompletedHandoffIfOwned()
OnboardingController.markComplete()
OnboardingController.shared.close()
// Land people in the real conversation, not on an empty desktop: the
// agent chat is the product, and it is verified working by now.
if state.connectionMode != .unconfigured {
AppNavigationActions.openChat(draft: agentDraft?.composerValue)
guard state.connectionMode != .unconfigured else { return }
// An explicit agent handoff from the helper chat carries a composer
// draft; land that in the chat it was written for.
if let agentDraft {
AppNavigationActions.openChat(draft: agentDraft.composerValue)
return
}
}
func advancePastEmptyMemoryImportIfNeeded() {
guard self.memoryImport.autoAdvanceRequested else { return }
withAnimation {
self.memoryImport.consumeAutoAdvanceRequest()
}
self.updateMonitoring(for: self.activePageIndex)
// Inference works; the dashboard's custodian onboarding owns the rest
// (memory import, channels, permissions guidance, hatch).
AppNavigationActions.openDashboardOnboarding()
}
}
@@ -61,18 +61,6 @@ extension OnboardingView {
guard installed else { return }
self.updateMonitoring(for: self.activePageIndex)
}
.onChange(of: aiSetup.connected) { _, connected in
guard connected else { return }
self.maybeStartMemoryImportPlanning()
}
.onChange(of: memoryImport.autoAdvanceRequested) { _, requested in
guard requested else { return }
self.advancePastEmptyMemoryImportIfNeeded()
}
.onChange(of: memoryImport.pageEligible) { wasEligible, isEligible in
guard wasEligible, !isEligible else { return }
self.reconcileCursorAfterMemoryImportRemoval()
}
.onDisappear {
self.onboardingDidDisappear()
}
@@ -105,7 +93,6 @@ extension OnboardingView {
// Queued detection can otherwise proceed into a mutating activation
// after the window or its selected route has gone away.
aiSetup.resetForGatewayChange(clearPendingHandoff: false)
memoryImport.reset()
systemAgentState.resetForGatewayChange()
stopPermissionMonitoring()
stopDiscovery()
@@ -117,24 +104,6 @@ extension OnboardingView {
return pageOrder[clamped]
}
func reconcileCursorAfterMemoryImportRemoval() {
guard self.state.connectionMode == .local else { return }
let previousOrder = Self.pageOrder(
for: .local,
requiresCLIInstall: !self.cliInstalled,
memoryImportEligible: true)
let newOrder = Self.pageOrder(
for: .local,
requiresCLIInstall: !self.cliInstalled,
memoryImportEligible: false)
let target = Self.reconciledPageCursor(
currentPage: self.currentPage,
previousOrder: previousOrder,
newOrder: newOrder)
guard target != self.currentPage else { return }
withAnimation { self.currentPage = target }
}
func reconcilePageForModeChange(previousActivePageIndex: Int) {
if let exact = pageOrder.firstIndex(of: previousActivePageIndex) {
withAnimation { self.currentPage = exact }
@@ -170,7 +139,6 @@ extension OnboardingView {
// The UI attempt belongs to one route, but its durable activation lease
// must survive A -> B -> A while the old Gateway can still be mutating.
aiSetup.resetForGatewayChange(clearPendingHandoff: false)
memoryImport.reset()
// OpenClaw sessions belong to one Gateway. Dismiss and replace the chat so
// changing routes cannot send an old session ID to the new endpoint.
systemAgentState.resetForGatewayChange()
@@ -397,7 +365,7 @@ extension OnboardingView {
.buttonStyle(.plain)
.foregroundColor(.secondary)
.opacity(0.8)
.disabled(self.installingCLI || self.aiSetup.isBusy || self.memoryImport.isApplying)
.disabled(self.installingCLI || self.aiSetup.isBusy)
.transition(.opacity.combined(with: .scale(scale: 0.9)))
}
}
@@ -407,8 +375,7 @@ extension OnboardingView {
HStack(spacing: 8) {
ForEach(0..<self.pageCount, id: \.self) { index in
let isInstallLocked = (self.installingCLI || self.aiSetup.isBusy ||
self.memoryImport.isApplying) &&
let isInstallLocked = (self.installingCLI || self.aiSetup.isBusy) &&
index != self.currentPage
let isConnectionLocked = self.isConnectionSelectionBlocking &&
index > (connectionLockIndex ?? 0)
@@ -1,219 +0,0 @@
import SwiftUI
extension OnboardingView {
func memoryImportPage(contentHeight: CGFloat) -> some View {
VStack(spacing: 12) {
Text("Bring your memories along")
.font(.largeTitle.weight(.semibold))
Text("OpenClaw can bring useful context from AI tools you already use into your new assistant.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 540)
.fixedSize(horizontal: false, vertical: true)
ScrollView {
self.memoryImportContent
.padding(.vertical, 4)
.padding(.trailing, 12)
}
.scrollIndicators(.automatic)
}
.padding(.horizontal, 28)
.padding(.top, 48)
.frame(width: self.pageWidth, height: contentHeight, alignment: .top)
}
@ViewBuilder
private var memoryImportContent: some View {
switch self.memoryImport.phase {
case .idle, .planning:
self.memoryImportProgress(
title: "Looking for memories…",
detail: "Checking supported AI tools on this Mac.")
case .offer:
self.memoryImportOffer(applying: false)
case .empty:
self.memoryImportProgress(
title: "No memories to import",
detail: "You can import memories later from the dashboard.")
case let .failed(message):
self.memoryImportFailure(message: message)
case .applying:
self.memoryImportOffer(applying: true)
case let .done(results):
self.memoryImportResults(results)
}
}
private func memoryImportOffer(applying: Bool) -> some View {
VStack(spacing: 14) {
self.onboardingCard(spacing: 0, padding: 0) {
ForEach(Array(self.memoryImport.providers.enumerated()), id: \.element.id) { index, provider in
if index > 0 { Divider() }
self.memoryImportProviderRow(provider)
.padding(14)
}
}
Button {
Task { await self.memoryImport.importSelected(gateway: self.memoryImportGateway) }
} label: {
HStack(spacing: 8) {
if applying {
ProgressView()
.controlSize(.small)
}
Text(applying ? "Importing memories…" : "Import memories")
}
.frame(minWidth: 150)
}
.buttonStyle(.borderedProminent)
.disabled(applying || !self.memoryImport.hasSelectedProviders)
if !applying, self.memoryImport.hasReplanRequired {
Button("Refresh plan") {
Task { await self.memoryImport.startPlanning(gateway: self.memoryImportGateway) }
}
.buttonStyle(.bordered)
.disabled(!self.memoryImport.canReplan)
if !self.memoryImport.canReplan {
Text("Retry pending imports before refreshing the plan.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
Text("You can skip this and import later from the dashboards Memory import page.")
.font(.footnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: 520)
}
private func memoryImportProviderRow(_ provider: OnboardingMemoryImportModel.Provider) -> some View {
Toggle(isOn: Binding(
get: { self.memoryImport.providers.first(where: { $0.id == provider.id })?.selected ?? false },
set: { self.memoryImport.setSelected($0, providerId: provider.providerId) }))
{
VStack(alignment: .leading, spacing: 4) {
Text(provider.label)
.font(.headline)
Text(
"\(provider.plannedCount) \(self.memoryLabel(provider.plannedCount)) · " +
(provider.source ?? "local files"))
.font(.subheadline)
.foregroundStyle(.secondary)
if provider.alreadyImportedCount > 0 {
Text("\(provider.alreadyImportedCount) already imported")
.font(.footnote)
.foregroundStyle(.secondary)
}
if let result = provider.result {
Text("Imported \(result.migrated) \(self.memoryLabel(result.migrated)).")
.font(.footnote.weight(.semibold))
.foregroundStyle(.green)
}
if let error = provider.inlineError {
Text(error)
.font(.footnote)
.foregroundStyle(.red)
.fixedSize(horizontal: false, vertical: true)
}
}
}
.toggleStyle(.checkbox)
.disabled(self.memoryImport.isApplying || !provider.isActionable)
}
private func memoryImportProgress(title: String, detail: String) -> some View {
self.onboardingCard {
HStack(spacing: 14) {
ProgressView()
.controlSize(.regular)
VStack(alignment: .leading, spacing: 3) {
Text(title).font(.headline)
Text(detail)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
.frame(maxWidth: 520)
}
private func memoryImportFailure(message: String) -> some View {
VStack(spacing: 14) {
self.onboardingCard {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.title2)
.foregroundStyle(.orange)
VStack(alignment: .leading, spacing: 5) {
Text("Couldnt check for memories")
.font(.headline)
Text(message)
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
Button("Retry") {
Task { await self.memoryImport.startPlanning(gateway: self.memoryImportGateway) }
}
.buttonStyle(.borderedProminent)
Text("You can do this later from the dashboards Memory import page.")
.font(.footnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: 520)
}
private func memoryImportResults(_ results: [OnboardingMemoryImportModel.ProviderResult]) -> some View {
let imported = results.reduce(0) { $0 + $1.migrated }
return VStack(spacing: 14) {
self.onboardingCard(spacing: 12) {
Label("Your memories are ready", systemImage: "checkmark.circle.fill")
.font(.headline)
.foregroundStyle(.green)
Text("Imported \(imported) \(self.memoryLabel(imported)) into OpenClaw.")
.font(.body)
ForEach(results) { result in
HStack {
Text(result.label)
Spacer()
Text("\(result.migrated) imported")
.foregroundStyle(.secondary)
}
.font(.subheadline)
}
}
Text("You can manage imported memories anytime from the dashboard.")
.font(.footnote)
.foregroundStyle(.secondary)
}
.frame(maxWidth: 520)
}
private func memoryLabel(_ count: Int) -> String {
count == 1 ? "memory" : "memories"
}
func updateMemoryImportMonitoring(for pageIndex: Int) {
self.memoryImport.setPageActive(pageIndex == self.memoryImportPageIndex)
self.maybeStartMemoryImportPlanning()
}
func maybeStartMemoryImportPlanning() {
guard self.state.connectionMode == .local,
self.aiSetup.connected,
self.memoryImport.shouldStartAutomatically
else { return }
Task { await self.memoryImport.startPlanning(gateway: self.memoryImportGateway) }
}
}
@@ -44,7 +44,6 @@ extension OnboardingView {
self.updateDiscoveryMonitoring(for: pageIndex)
self.maybeInstallCLI(for: pageIndex)
self.maybeStartAISetup(for: pageIndex)
self.updateMemoryImportMonitoring(for: pageIndex)
}
func maybeInstallCLI(for pageIndex: Int) {
@@ -16,8 +16,6 @@ extension OnboardingView {
self.cliPage()
case 3:
self.aiSetupPage(contentHeight: contentHeight)
case 4:
self.memoryImportPage(contentHeight: contentHeight)
case 5:
self.permissionsPage()
case 9:
@@ -39,7 +39,6 @@ extension OnboardingView {
_ = view.welcomePage()
_ = view.connectionPage()
_ = view.aiSetupPage(contentHeight: contentHeight)
_ = view.memoryImportPage(contentHeight: contentHeight)
_ = view.permissionsPage()
_ = view.cliPage()
_ = view.readyPage()
@@ -43,7 +43,6 @@ extension OnboardingView {
case connection
case cli
case ai
case memory
case permissions
case chat
case ready
@@ -58,7 +57,6 @@ extension OnboardingView {
var aiPhase: OnboardingAISetupModel.Phase = .idle
var aiBusy = false
var aiFailed = false
var memoryPhase: OnboardingMemoryImportModel.Phase = .idle
var remoteProbeState: RemoteOnboardingProbeState = .idle
var allPermissionsGranted = false
}
@@ -75,7 +73,6 @@ extension OnboardingView {
aiPhase: self.aiSetup.phase,
aiBusy: self.aiSetup.isBusy,
aiFailed: Self.aiSetupLooksFailed(self.aiSetup),
memoryPhase: self.memoryImport.phase,
remoteProbeState: self.remoteProbeState,
allPermissionsGranted: Capability.importanceOrdered
.allSatisfy { self.permissionMonitor.status[$0]?.isGranted == true }))
@@ -90,7 +87,6 @@ extension OnboardingView {
case self.connectionPageIndex: .connection
case self.cliPageIndex: .cli
case self.aiPageIndex: .ai
case self.memoryImportPageIndex: .memory
case self.permissionsPageIndex: .permissions
case self.onboardingChatPageIndex: .chat
case self.readyPageIndex: .ready
@@ -140,8 +136,6 @@ extension OnboardingView {
} else {
.curious
}
case .memory:
self.memoryImportMood(for: snapshot.memoryPhase)
case .permissions:
snapshot.allPermissionsGranted ? .happy : .curious
case .chat:
@@ -154,20 +148,7 @@ extension OnboardingView {
static func mascotAccessory(for page: MascotPage) -> OpenClawMascotAccessory {
switch page {
case .ready: .gradCap
case .welcome, .connection, .cli, .ai, .memory, .permissions, .chat: .none
}
}
static func memoryImportMood(for phase: OnboardingMemoryImportModel.Phase) -> OpenClawMascotMood {
switch phase {
case .planning, .applying:
.thinking
case .failed:
.sad
case .done:
.happy
case .idle, .offer, .empty:
.curious
case .welcome, .connection, .cli, .ai, .permissions, .chat: .none
}
}
}
@@ -6,11 +6,32 @@ enum TextSummarySupport {
.split(whereSeparator: \.isNewline)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
guard let last = lines.last else { return nil }
let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
guard !lines.isEmpty else { return nil }
// Node fatal errors end with a bare "Node.js vX.Y.Z" banner. Surfacing
// that line hides the actual failure ("Error: Cannot find module "),
// so drop the banner and prefer the last real error line above it.
var candidates = lines[...]
var droppedNodeBanner = false
while let last = candidates.last, Self.isNodeVersionBanner(last) {
candidates = candidates.dropLast()
droppedNodeBanner = true
}
var chosen = candidates.last ?? lines[lines.count - 1]
if droppedNodeBanner, let errorLine = candidates.suffix(40).last(where: self.isErrorLine) {
chosen = errorLine
}
let normalized = chosen.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
if normalized.count > maxLength {
return String(normalized.prefix(maxLength - 1)) + ""
}
return normalized
}
private static func isNodeVersionBanner(_ line: String) -> Bool {
line.range(of: #"^Node\.js v\d"#, options: .regularExpression) != nil
}
private static func isErrorLine(_ line: String) -> Bool {
line.range(of: #"^\w*Error(\b|:)"#, options: .regularExpression) != nil
}
}
@@ -1059,6 +1059,75 @@ struct OnboardingAISetupTests {
defaults: defaults) == .none)
}
@Test func `choose a different AI relists routes without auto-activating`() async throws {
let suiteName = "OnboardingChooseDifferentAITests-\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suiteName))
defer { defaults.removePersistentDomain(forName: suiteName) }
let recorder = AISetupRequestRecorder()
let session = GatewayTestWebSocketSession(taskFactory: {
GatewayTestWebSocketTask(sendHook: { task, message, sendIndex in
guard sendIndex > 0, let request = aiSetupRequest(from: message) else { return }
if respondToAISetupHealth(task: task, request: request) {
return
}
await recorder.record(message)
switch request.method {
case "openclaw.setup.detect":
// credentials:true would auto-activate; the choose-different
// pass must end at the picker even for actionable candidates.
task.emitReceiveSuccess(.data(actionableDetectedSetupResponse(id: request.id)))
case "openclaw.setup.activate":
task.emitReceiveSuccess(.data(verifiedSetupResponse(id: request.id)))
default:
break
}
})
})
let url = try #require(URL(string: "ws://example.invalid"))
let gateway = GatewayConnection(
configProvider: { (url: url, token: nil, password: nil) },
sessionBox: WebSocketSessionBox(session: session))
let model = OnboardingAISetupModel(
gateway: gateway,
defaults: defaults,
routeIdentityProvider: { "local" })
await model.detectAndAutoConnect()
#expect(model.connected)
#expect(model.beginChooseDifferentAI())
await model.detectAndAutoConnect()
#expect(!model.connected)
#expect(!model.isBusy)
#expect(model.candidates.count == 1)
#expect(model.statuses["claude-cli"] == .untried)
#expect(model.showManualEntry)
// Exactly one activation: the initial auto-connect. The re-detect pass
// must not redo the choice the user just asked to change.
let snapshot = await recorder.snapshot()
#expect(snapshot.methods.filter { $0 == "openclaw.setup.activate" }.count == 1)
#expect(snapshot.methods.last == "openclaw.setup.detect")
}
@Test func `choose a different AI requires a connected setup`() throws {
let suiteName = "OnboardingChooseDifferentAIGuardTests-\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName) ?? .standard
defer { defaults.removePersistentDomain(forName: suiteName) }
let url = try #require(URL(string: "ws://example.invalid"))
let gateway = GatewayConnection(
configProvider: { (url: url, token: nil, password: nil) },
sessionBox: WebSocketSessionBox(session: GatewayTestWebSocketSession(taskFactory: {
GatewayTestWebSocketTask(sendHook: { _, _, _ in })
})))
let model = OnboardingAISetupModel(
gateway: gateway,
defaults: defaults,
routeIdentityProvider: { "local" })
#expect(!model.beginChooseDifferentAI())
}
@Test func `adopts pending activation stored under the retired crestodian key`() throws {
let suiteName = "OnboardingRetiredKeyMigrationTests-\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suiteName))
@@ -1381,8 +1450,16 @@ struct OnboardingAISetupTests {
setupOwnsInferenceTransition: false))
}
@Test func `configured model label stays pending until live verification`() async {
let model = OnboardingAISetupModel()
@Test func `configured model label stays pending until live verification`() async throws {
// Isolated defaults + fixed route: the default init reads the machine's
// real resume store, whose leftover activation leases fail this test on
// any Mac that completed onboarding.
let suiteName = "OnboardingConfiguredLabelTests-\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suiteName))
defer { defaults.removePersistentDomain(forName: suiteName) }
let model = OnboardingAISetupModel(
defaults: defaults,
routeIdentityProvider: { "local" })
model.resumeConfiguredInference(modelRef: " openai/gpt-5.5 ")
@@ -47,12 +47,6 @@ struct OnboardingMascotMoodTests {
#expect(self.mood(.init(page: .permissions, allPermissionsGranted: true)) == .happy)
}
@Test func `memory import page follows import lifecycle`() {
#expect(self.mood(.init(page: .memory, memoryPhase: .planning)) == .thinking)
#expect(self.mood(.init(page: .memory, memoryPhase: .failed("offline"))) == .sad)
#expect(self.mood(.init(page: .memory, memoryPhase: .done([]))) == .happy)
}
@Test func `chat and ready pages`() {
#expect(self.mood(.init(page: .chat)) == .attentive)
#expect(self.mood(.init(page: .ready)) == .celebrating)
@@ -1,967 +0,0 @@
import Foundation
import Testing
@testable import OpenClaw
@testable import OpenClawKit
private struct MemoryImportWireRequest: Sendable {
let id: String
let method: String
let agentId: String?
let providerId: String?
let planFingerprint: String?
let itemIds: [String]
let overwrite: Bool?
let idempotencyKey: String?
}
private actor MemoryImportRequestRecorder {
private var requests: [MemoryImportWireRequest] = []
func record(_ request: MemoryImportWireRequest) {
self.requests.append(request)
}
func snapshot() -> [MemoryImportWireRequest] {
self.requests
}
}
private actor MemoryImportRequestGate {
private var started = false
private var released = false
private var startWaiters: [CheckedContinuation<Void, Never>] = []
private var releaseWaiters: [CheckedContinuation<Void, Never>] = []
func wait() async {
self.started = true
self.startWaiters.forEach { $0.resume() }
self.startWaiters.removeAll()
guard !self.released else { return }
await withCheckedContinuation { self.releaseWaiters.append($0) }
}
func waitUntilStarted() async {
guard !self.started else { return }
await withCheckedContinuation { self.startWaiters.append($0) }
}
func release() {
self.released = true
self.releaseWaiters.forEach { $0.resume() }
self.releaseWaiters.removeAll()
}
}
private actor MemoryImportApplyCounter {
private var counts: [String: Int] = [:]
func next(for providerId: String) -> Int {
let next = (self.counts[providerId] ?? 0) + 1
self.counts[providerId] = next
return next
}
}
private final class MemoryImportGatewayConfig: @unchecked Sendable {
private let lock = NSLock()
private let url: URL
private var token: String
init(url: URL, token: String) {
self.url = url
self.token = token
}
func setToken(_ token: String) {
self.lock.lock()
self.token = token
self.lock.unlock()
}
func snapshot() -> GatewayConnection.Config {
self.lock.lock()
defer { self.lock.unlock() }
return (url: self.url, token: self.token, password: nil)
}
}
private func memoryImportWireRequest(
from message: URLSessionWebSocketTask.Message) -> MemoryImportWireRequest?
{
let data: Data? = switch message {
case let .data(data): data
case let .string(string): string.data(using: .utf8)
@unknown default: nil
}
guard let data,
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let id = object["id"] as? String,
let method = object["method"] as? String
else { return nil }
let params = object["params"] as? [String: Any] ?? [:]
return MemoryImportWireRequest(
id: id,
method: method,
agentId: params["agentId"] as? String,
providerId: params["providerId"] as? String,
planFingerprint: params["planFingerprint"] as? String,
itemIds: params["itemIds"] as? [String] ?? [],
overwrite: params["overwrite"] as? Bool,
idempotencyKey: params["idempotencyKey"] as? String)
}
private func memoryImportOK(id: String, payload: String) -> Data {
Data(#"{"type":"res","id":"\#(id)","ok":true,"payload":\#(payload)}"#.utf8)
}
private func memoryImportError(id: String, message: String) -> Data {
Data(
#"{"type":"res","id":"\#(id)","ok":false,"error":{"code":"INVALID_REQUEST","message":"\#(message)"}}"#.utf8)
}
private let memoryImportEmptyPlanPayload = #"{"agentId":"main","workspace":"/tmp/workspace","providers":[]}"#
private let memoryImportOfferPlanPayload = #"""
{
"agentId":"main",
"workspace":"/tmp/workspace",
"providers":[
{
"providerId":"claude",
"label":"Claude",
"planFingerprint":"plan-claude",
"found":true,
"source":"~/.claude",
"summary":{"total":3,"planned":2,"migrated":0,"skipped":0,"conflicts":1,"errors":0,"sensitive":0},
"items":[
{"id":"planned-1","status":"planned"},
{"id":"conflict-1","status":"conflict"},
{"id":"planned-2","status":"planned"}
]
},
{
"providerId":"codex",
"label":"Codex",
"planFingerprint":"plan-codex",
"found":true,
"source":"~/.codex",
"summary":{"total":1,"planned":1,"migrated":0,"skipped":0,"conflicts":0,"errors":0,"sensitive":0},
"items":[{"id":"codex-1","status":"planned"}]
}
]
}
"""#
private let memoryImportProviderErrorPlanPayload = #"""
{
"agentId":"main",
"workspace":"/tmp/workspace",
"providers":[
{
"providerId":"claude",
"label":"Claude",
"found":true,
"source":"~/.claude",
"summary":{"total":0,"planned":0,"migrated":0,"skipped":0,"conflicts":0,"errors":1,"sensitive":0},
"items":[],
"error":"Could not read Claude memories"
}
]
}
"""#
private let memoryImportSummaryErrorPlanPayload = #"""
{
"agentId":"main",
"workspace":"/tmp/workspace",
"providers":[
{
"providerId":"claude",
"label":"Claude",
"found":true,
"summary":{"total":0,"planned":0,"migrated":0,"skipped":0,"conflicts":0,"errors":1,"sensitive":0},
"items":[]
}
]
}
"""#
private let memoryImportMissingFingerprintPlanPayload = #"""
{
"agentId":"main",
"workspace":"/tmp/workspace",
"providers":[
{
"providerId":"claude",
"label":"Claude",
"found":true,
"source":"~/.claude",
"summary":{"total":1,"planned":1,"migrated":0,"skipped":0,"conflicts":0,"errors":0,"sensitive":0},
"items":[{"id":"planned-1","status":"planned"}]
}
]
}
"""#
private let memoryImportInconsistentProviderPlanPayload = #"""
{
"agentId":"main",
"workspace":"/tmp/workspace",
"providers":[
{
"providerId":"claude",
"label":"Claude",
"planFingerprint":"plan-claude",
"found":true,
"summary":{"total":1,"planned":1,"migrated":0,"skipped":0,"conflicts":0,"errors":0,"sensitive":0},
"items":[{"id":"planned-1","status":"planned"}]
},
{
"providerId":"broken",
"label":"Broken",
"planFingerprint":"plan-broken",
"found":false,
"summary":{"total":1,"planned":1,"migrated":0,"skipped":0,"conflicts":0,"errors":0,"sensitive":0},
"items":[{"id":"broken-1","status":"planned"}]
}
]
}
"""#
private let memoryImportDuplicateProviderPlanPayload = #"""
{
"agentId":"main",
"workspace":"/tmp/workspace",
"providers":[
{
"providerId":"duplicate",
"label":"First",
"found":false,
"summary":{"total":0,"planned":0,"migrated":0,"skipped":0,"conflicts":0,"errors":0,"sensitive":0},
"items":[]
},
{
"providerId":"duplicate",
"label":"Second",
"found":false,
"summary":{"total":0,"planned":0,"migrated":0,"skipped":0,"conflicts":0,"errors":0,"sensitive":0},
"items":[]
}
]
}
"""#
private func memoryImportApplyPayload(providerId: String, migrated: Int, errors: Int = 0) -> String {
#"{"providerId":"\#(providerId)","source":"local","summary":{"total":\#(migrated + errors),"planned":0,"migrated":\#(migrated),"skipped":0,"conflicts":0,"errors":\#(errors),"sensitive":0},"items":[]}"#
}
private func makeMemoryImportGateway(
configProvider: @escaping @Sendable () async throws -> GatewayConnection.Config,
responder: @escaping @Sendable (GatewayTestWebSocketTask, MemoryImportWireRequest) async -> Void)
-> GatewayConnection
{
GatewayConnection(
configProvider: configProvider,
sessionBox: WebSocketSessionBox(session: GatewayTestWebSocketSession(taskFactory: {
GatewayTestWebSocketTask(sendHook: { task, message, sendIndex in
guard sendIndex > 0, let request = memoryImportWireRequest(from: message) else { return }
await responder(task, request)
})
})))
}
@Suite(.serialized)
@MainActor
struct OnboardingMemoryImportTests {
private func withTemporaryStateDir<T>(_ operation: () async throws -> T) async throws -> T {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
return try await DeviceIdentityStore.withStateDirectory(tempDir) {
try await operation()
}
}
@Test func `plan maps planned and already imported memories into an offer`() async throws {
let url = try #require(URL(string: "ws://memory.test"))
let gateway = makeMemoryImportGateway(
configProvider: { (url: url, token: nil, password: nil) },
responder: { task, request in
let payload = request.method == "health" ? "{}" : memoryImportOfferPlanPayload
task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload)))
})
let model = OnboardingMemoryImportModel()
await model.startPlanning(gateway: gateway, agentId: "main")
#expect(model.hasOffer)
#expect(model.providers.count == 2)
#expect(model.providers[0].providerId == "claude")
#expect(model.providers[0].plannedCount == 2)
#expect(model.providers[0].alreadyImportedCount == 1)
#expect(model.providers[0].selected)
#expect(model.providers[1].plannedCount == 1)
}
@Test func `empty plan resolves without an offer`() async throws {
let url = try #require(URL(string: "ws://memory.test"))
let gateway = makeMemoryImportGateway(
configProvider: { (url: url, token: nil, password: nil) },
responder: { task, request in
let payload = request.method == "health" ? "{}" : memoryImportEmptyPlanPayload
task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload)))
})
let model = OnboardingMemoryImportModel()
await model.startPlanning(gateway: gateway, agentId: "main")
#expect(model.resolvedEmpty)
#expect(!model.hasOffer)
#expect(!model.pageEligible)
}
@Test func `provider planning error remains retryable instead of resolving empty`() async throws {
let url = try #require(URL(string: "ws://memory.test"))
let gateway = makeMemoryImportGateway(
configProvider: { (url: url, token: nil, password: nil) },
responder: { task, request in
let payload = request.method == "migrations.memory.plan"
? memoryImportProviderErrorPlanPayload
: "{}"
task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload)))
})
let model = OnboardingMemoryImportModel()
await model.startPlanning(gateway: gateway, agentId: "main")
guard case let .failed(message) = model.phase else {
Issue.record("Expected a retryable planning failure")
return
}
#expect(message == "Claude: Could not read Claude memories")
#expect(!model.resolvedEmpty)
#expect(model.pageEligible)
}
@Test func `provider summary error remains retryable without an error string`() async throws {
let url = try #require(URL(string: "ws://memory.test"))
let gateway = makeMemoryImportGateway(
configProvider: { (url: url, token: nil, password: nil) },
responder: { task, request in
let payload = request.method == "migrations.memory.plan"
? memoryImportSummaryErrorPlanPayload
: "{}"
task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload)))
})
let model = OnboardingMemoryImportModel()
await model.startPlanning(gateway: gateway, agentId: "main")
guard case let .failed(message) = model.phase else {
Issue.record("Expected summary error to remain retryable")
return
}
#expect(message.contains("could not plan 1 memory"))
#expect(!model.resolvedEmpty)
}
@Test func `missing plan fingerprint remains retryable instead of offering import`() async throws {
let url = try #require(URL(string: "ws://memory.test"))
let gateway = makeMemoryImportGateway(
configProvider: { (url: url, token: nil, password: nil) },
responder: { task, request in
let payload = request.method == "migrations.memory.plan"
? memoryImportMissingFingerprintPlanPayload
: "{}"
task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload)))
})
let model = OnboardingMemoryImportModel()
await model.startPlanning(gateway: gateway, agentId: "main")
guard case let .failed(message) = model.phase else {
Issue.record("Expected a retryable missing-fingerprint failure")
return
}
#expect(message.contains("usable import plan"))
#expect(!model.hasOffer)
#expect(model.pageEligible)
}
@Test func `inconsistent provider stays disabled beside a valid offer`() async throws {
let url = try #require(URL(string: "ws://memory.test"))
let gateway = makeMemoryImportGateway(
configProvider: { (url: url, token: nil, password: nil) },
responder: { task, request in
let payload = request.method == "migrations.memory.plan"
? memoryImportInconsistentProviderPlanPayload
: "{}"
task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload)))
})
let model = OnboardingMemoryImportModel()
await model.startPlanning(gateway: gateway, agentId: "main")
#expect(model.hasOffer)
let broken = try #require(model.providers.first { $0.providerId == "broken" })
#expect(!broken.selected)
#expect(!broken.isActionable)
#expect(broken.requiresReplan)
#expect(broken.inlineError?.contains("inconsistent") == true)
}
@Test func `foreign agent identity rejects the plan`() async throws {
try await self.withTemporaryStateDir {
let url = try #require(URL(string: "ws://memory.test"))
let gateway = makeMemoryImportGateway(
configProvider: { (url: url, token: nil, password: nil) },
responder: { task, request in
let payload = request.method == "migrations.memory.plan"
? #"{"agentId":"other","workspace":"/tmp/workspace","providers":[]}"#
: "{}"
task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload)))
})
let model = OnboardingMemoryImportModel()
await model.startPlanning(gateway: gateway, agentId: "main")
guard case let .failed(message) = model.phase else {
Issue.record("Expected foreign-agent plan failure")
return
}
#expect(message.contains("different agent"))
}
}
@Test func `duplicate provider identity rejects the plan`() async throws {
try await self.withTemporaryStateDir {
let url = try #require(URL(string: "ws://memory.test"))
let gateway = makeMemoryImportGateway(
configProvider: { (url: url, token: nil, password: nil) },
responder: { task, request in
let payload = request.method == "migrations.memory.plan"
? memoryImportDuplicateProviderPlanPayload
: "{}"
task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload)))
})
let model = OnboardingMemoryImportModel()
await model.startPlanning(gateway: gateway, agentId: "main")
guard case let .failed(message) = model.phase else {
Issue.record("Expected duplicate-provider plan failure")
return
}
#expect(message.contains("invalid memory provider"))
}
}
@Test func `default agent id feeds the plan request`() async throws {
let url = try #require(URL(string: "ws://memory.test"))
let recorder = MemoryImportRequestRecorder()
let gateway = makeMemoryImportGateway(
configProvider: { (url: url, token: nil, password: nil) },
responder: { task, request in
await recorder.record(request)
let payload = switch request.method {
case "agents.list": #"{"defaultId":"work"}"#
case "migrations.memory.plan": #"{"agentId":"work","workspace":"/tmp/workspace","providers":[]}"#
default: "{}"
}
task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload)))
})
let model = OnboardingMemoryImportModel()
await model.startPlanning(gateway: gateway)
let plan = try #require(await recorder.snapshot().first { $0.method == "migrations.memory.plan" })
#expect(plan.agentId == "work")
#expect(plan.overwrite == false)
#expect(model.resolvedEmpty)
}
@Test func `apply sends planned ids and continues after a provider error`() async throws {
let url = try #require(URL(string: "ws://memory.test"))
let recorder = MemoryImportRequestRecorder()
let counter = MemoryImportApplyCounter()
let gateway = makeMemoryImportGateway(
configProvider: { (url: url, token: nil, password: nil) },
responder: { task, request in
await recorder.record(request)
if request.method == "migrations.memory.apply",
request.providerId == "claude",
await counter.next(for: "claude") == 1
{
task.emitReceiveSuccess(.data(memoryImportError(id: request.id, message: "Claude import failed")))
return
}
let payload = switch request.method {
case "migrations.memory.plan": memoryImportOfferPlanPayload
case "migrations.memory.apply": memoryImportApplyPayload(
providerId: request.providerId ?? "unknown",
migrated: request.providerId == "claude" ? 2 : 1)
default: "{}"
}
task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload)))
})
let model = OnboardingMemoryImportModel()
await model.startPlanning(gateway: gateway, agentId: "main")
await model.importSelected(gateway: gateway)
#expect(model.providers.first(where: { $0.providerId == "claude" })?.inlineError != nil)
#expect(model.providers.first(where: { $0.providerId == "codex" })?.result?.migrated == 1)
let applyRequests = await recorder.snapshot().filter { $0.method == "migrations.memory.apply" }
#expect(applyRequests.count == 2)
let claude = try #require(applyRequests.first { $0.providerId == "claude" })
#expect(claude.agentId == "main")
#expect(claude.planFingerprint == "plan-claude")
#expect(claude.itemIds == ["planned-1", "planned-2"])
#expect(claude.overwrite == false)
let keys = Set(applyRequests.compactMap(\.idempotencyKey))
#expect(keys.count == 2)
#expect(model.hasReplanRequired)
await model.startPlanning(gateway: gateway, agentId: "main")
#expect(model.hasOffer)
#expect(!model.hasReplanRequired)
#expect(model.providers.first { $0.providerId == "claude" }?.inlineError == nil)
let carriedCodex = try #require(model.providers.first { $0.providerId == "codex" })
#expect(carriedCodex.result?.migrated == 1)
#expect(!carriedCodex.selected)
#expect(!carriedCodex.isActionable)
}
@Test func `ambiguous apply response reuses its idempotency key`() async throws {
let url = try #require(URL(string: "ws://memory.test"))
let recorder = MemoryImportRequestRecorder()
let counter = MemoryImportApplyCounter()
let gateway = makeMemoryImportGateway(
configProvider: { (url: url, token: nil, password: nil) },
responder: { task, request in
await recorder.record(request)
let payload: String = if request.method == "migrations.memory.plan" {
memoryImportOfferPlanPayload
} else if request.method == "migrations.memory.apply",
await counter.next(for: request.providerId ?? "unknown") == 1
{
"{}"
} else if request.method == "migrations.memory.apply" {
memoryImportApplyPayload(
providerId: request.providerId ?? "unknown",
migrated: 2)
} else {
"{}"
}
task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload)))
})
let model = OnboardingMemoryImportModel()
await model.startPlanning(gateway: gateway, agentId: "main")
model.setSelected(false, providerId: "codex")
await model.importSelected(gateway: gateway)
#expect(model.providers.first?.inlineError != nil)
await model.importSelected(gateway: gateway)
let attempts = await recorder.snapshot().filter { $0.method == "migrations.memory.apply" }
#expect(attempts.count == 2)
#expect(attempts[0].idempotencyKey == attempts[1].idempotencyKey)
#expect(model.results.first?.migrated == 2)
}
@Test func `ambiguous retry must resolve before a deterministic failure can replan`() async throws {
let url = try #require(URL(string: "ws://memory.test"))
let recorder = MemoryImportRequestRecorder()
let counter = MemoryImportApplyCounter()
let gateway = makeMemoryImportGateway(
configProvider: { (url: url, token: nil, password: nil) },
responder: { task, request in
await recorder.record(request)
if request.method == "migrations.memory.apply", request.providerId == "claude" {
let attempt = await counter.next(for: "claude")
let payload = attempt == 1
? "{}"
: memoryImportApplyPayload(providerId: "claude", migrated: 2)
task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload)))
return
}
if request.method == "migrations.memory.apply" {
task.emitReceiveSuccess(.data(memoryImportError(id: request.id, message: "refresh required")))
return
}
let payload = request.method == "migrations.memory.plan" ? memoryImportOfferPlanPayload : "{}"
task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload)))
})
let model = OnboardingMemoryImportModel()
await model.startPlanning(gateway: gateway, agentId: "main")
await model.importSelected(gateway: gateway)
#expect(model.hasReplanRequired)
#expect(!model.canReplan)
model.setSelected(false, providerId: "claude")
model.setSelected(true, providerId: "claude")
#expect(!model.canReplan)
let firstClaudeKey = try #require(await recorder.snapshot().first {
$0.method == "migrations.memory.apply" && $0.providerId == "claude"
}?.idempotencyKey)
await model.startPlanning(gateway: gateway, agentId: "main")
#expect(await recorder.snapshot().count { $0.method == "migrations.memory.plan" } == 1)
await model.importSelected(gateway: gateway)
let claudeKeys = await recorder.snapshot().filter {
$0.method == "migrations.memory.apply" && $0.providerId == "claude"
}.compactMap(\.idempotencyKey)
#expect(claudeKeys == [firstClaudeKey, firstClaudeKey])
#expect(model.canReplan)
}
@Test func `apply response for another provider is rejected without consuming retry identity`() async throws {
let url = try #require(URL(string: "ws://memory.test"))
let recorder = MemoryImportRequestRecorder()
let gateway = makeMemoryImportGateway(
configProvider: { (url: url, token: nil, password: nil) },
responder: { task, request in
await recorder.record(request)
let payload = switch request.method {
case "migrations.memory.plan": memoryImportOfferPlanPayload
case "migrations.memory.apply": memoryImportApplyPayload(providerId: "codex", migrated: 2)
default: "{}"
}
task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload)))
})
let model = OnboardingMemoryImportModel()
await model.startPlanning(gateway: gateway, agentId: "main")
model.setSelected(false, providerId: "codex")
await model.importSelected(gateway: gateway)
await model.importSelected(gateway: gateway)
let claude = try #require(model.providers.first { $0.providerId == "claude" })
#expect(claude.result == nil)
#expect(claude.inlineError?.contains("different memory provider") == true)
let keys = await recorder.snapshot().filter {
$0.method == "migrations.memory.apply"
}.compactMap(\.idempotencyKey)
#expect(keys.count == 2)
#expect(keys[0] == keys[1])
}
@Test func `fresh replan keeps completed totals while offering newly planned items`() async throws {
let url = try #require(URL(string: "ws://memory.test"))
let counter = MemoryImportApplyCounter()
let gateway = makeMemoryImportGateway(
configProvider: { (url: url, token: nil, password: nil) },
responder: { task, request in
let payload: String
if request.method == "migrations.memory.plan" {
let attempt = await counter.next(for: "plan")
payload = attempt == 1
? memoryImportOfferPlanPayload
: memoryImportOfferPlanPayload
.replacingOccurrences(of: "plan-codex", with: "plan-codex-new")
.replacingOccurrences(of: "codex-1", with: "codex-new")
} else if request.method == "migrations.memory.apply", request.providerId == "claude" {
task.emitReceiveSuccess(.data(memoryImportError(id: request.id, message: "refresh required")))
return
} else if request.method == "migrations.memory.apply" {
payload = memoryImportApplyPayload(providerId: "codex", migrated: 1)
} else {
payload = "{}"
}
task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload)))
})
let model = OnboardingMemoryImportModel()
await model.startPlanning(gateway: gateway, agentId: "main")
await model.importSelected(gateway: gateway)
await model.startPlanning(gateway: gateway, agentId: "main")
let codex = try #require(model.providers.first { $0.providerId == "codex" })
#expect(codex.result?.migrated == 1)
#expect(codex.plannedItemIds == ["codex-new"])
#expect(codex.selected)
#expect(codex.isActionable)
}
@Test func `reset invalidates an in flight batch before the next provider`() async throws {
let url = try #require(URL(string: "ws://memory.test"))
let recorder = MemoryImportRequestRecorder()
let gate = MemoryImportRequestGate()
let gateway = makeMemoryImportGateway(
configProvider: { (url: url, token: nil, password: nil) },
responder: { task, request in
await recorder.record(request)
if request.method == "migrations.memory.apply" {
await gate.wait()
task.emitReceiveSuccess(.data(memoryImportOK(
id: request.id,
payload: memoryImportApplyPayload(
providerId: request.providerId ?? "unknown",
migrated: 1))))
return
}
let payload = request.method == "migrations.memory.plan" ? memoryImportOfferPlanPayload : "{}"
task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload)))
})
let model = OnboardingMemoryImportModel()
await model.startPlanning(gateway: gateway, agentId: "main")
let applying = Task { await model.importSelected(gateway: gateway) }
await gate.waitUntilStarted()
model.reset()
await gate.release()
await applying.value
#expect(model.phase == .idle)
#expect(await recorder.snapshot().count { $0.method == "migrations.memory.apply" } == 1)
}
@Test func `replan for another agent discards completed-provider carryover`() async throws {
let url = try #require(URL(string: "ws://memory.test"))
let gateway = makeMemoryImportGateway(
configProvider: { (url: url, token: nil, password: nil) },
responder: { task, request in
if request.method == "migrations.memory.apply", request.providerId == "claude" {
task.emitReceiveSuccess(.data(memoryImportError(id: request.id, message: "refresh required")))
return
}
let payload = switch request.method {
case "migrations.memory.plan": request.agentId == "other"
? memoryImportOfferPlanPayload.replacingOccurrences(
of: #""agentId":"main""#,
with: #""agentId":"other""#)
: memoryImportOfferPlanPayload
case "migrations.memory.apply": memoryImportApplyPayload(providerId: "codex", migrated: 1)
default: "{}"
}
task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload)))
})
let model = OnboardingMemoryImportModel()
await model.startPlanning(gateway: gateway, agentId: "main")
await model.importSelected(gateway: gateway)
#expect(model.providers.first { $0.providerId == "codex" }?.result?.migrated == 1)
await model.startPlanning(gateway: gateway, agentId: "other")
#expect(model.providers.first { $0.providerId == "codex" }?.result == nil)
#expect(model.providers.first { $0.providerId == "codex" }?.selected == true)
}
@Test func `failed refresh preserves fresh errors and completed carryover for retry`() async throws {
let url = try #require(URL(string: "ws://memory.test"))
let counter = MemoryImportApplyCounter()
let gateway = makeMemoryImportGateway(
configProvider: { (url: url, token: nil, password: nil) },
responder: { task, request in
if request.method == "migrations.memory.apply", request.providerId == "codex" {
task.emitReceiveSuccess(.data(memoryImportError(id: request.id, message: "refresh required")))
return
}
let payload: String
if request.method == "migrations.memory.plan" {
let attempt = await counter.next(for: "plan")
payload = attempt == 2 ? memoryImportProviderErrorPlanPayload : memoryImportOfferPlanPayload
} else if request.method == "migrations.memory.apply" {
payload = memoryImportApplyPayload(providerId: "claude", migrated: 2)
} else {
payload = "{}"
}
task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload)))
})
let model = OnboardingMemoryImportModel()
await model.startPlanning(gateway: gateway, agentId: "main")
await model.importSelected(gateway: gateway)
#expect(model.providers.first { $0.providerId == "claude" }?.result?.migrated == 2)
await model.startPlanning(gateway: gateway, agentId: "main")
guard case let .failed(message) = model.phase else {
Issue.record("Expected the fresh provider error to fail refresh")
return
}
#expect(message.contains("Could not read Claude memories"))
await model.startPlanning(gateway: gateway, agentId: "main")
let claude = try #require(model.providers.first { $0.providerId == "claude" })
#expect(claude.result?.migrated == 2)
#expect(!claude.selected)
}
@Test func `apply rejects an offer from a replaced server lease`() async throws {
let url = try #require(URL(string: "ws://memory.test"))
let config = MemoryImportGatewayConfig(url: url, token: "first")
let recorder = MemoryImportRequestRecorder()
let gateway = makeMemoryImportGateway(
configProvider: { config.snapshot() },
responder: { task, request in
await recorder.record(request)
let payload = request.method == "migrations.memory.plan" ? memoryImportOfferPlanPayload : "{}"
task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload)))
})
let model = OnboardingMemoryImportModel()
await model.startPlanning(gateway: gateway, agentId: "main")
config.setToken("replacement")
await model.importSelected(gateway: gateway)
#expect(model.isFailed)
#expect(await recorder.snapshot().allSatisfy { $0.method != "migrations.memory.apply" })
}
@Test func `stale lease plan response is ignored`() async throws {
let url = try #require(URL(string: "ws://memory.test"))
let config = MemoryImportGatewayConfig(url: url, token: "first")
let gate = MemoryImportRequestGate()
let gateway = makeMemoryImportGateway(
configProvider: { config.snapshot() },
responder: { task, request in
if request.method == "migrations.memory.plan" {
await gate.wait()
task.emitReceiveSuccess(.data(memoryImportOK(
id: request.id,
payload: memoryImportOfferPlanPayload)))
return
}
task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: "{}")))
})
let model = OnboardingMemoryImportModel()
let planning = Task { await model.startPlanning(gateway: gateway, agentId: "main") }
await gate.waitUntilStarted()
config.setToken("replacement")
await gate.release()
await planning.value
guard case .failed = model.phase else {
Issue.record("Expected a retryable failure after the stale plan response")
return
}
#expect(!model.hasOffer)
#expect(model.pageEligible)
}
@Test func `active empty page requests auto advance before becoming ineligible`() async throws {
let url = try #require(URL(string: "ws://memory.test"))
let gateway = makeMemoryImportGateway(
configProvider: { (url: url, token: nil, password: nil) },
responder: { task, request in
let payload = request.method == "health" ? "{}" : memoryImportEmptyPlanPayload
task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload)))
})
let model = OnboardingMemoryImportModel()
model.setPageActive(true)
await model.startPlanning(gateway: gateway, agentId: "main")
#expect(model.resolvedEmpty)
#expect(model.autoAdvanceRequested)
#expect(model.pageEligible)
model.consumeAutoAdvanceRequest()
#expect(!model.autoAdvanceRequested)
#expect(!model.pageEligible)
}
@Test func `dismissed planning failure stays ineligible for automatic retry`() async throws {
let url = try #require(URL(string: "ws://memory.test"))
let gateway = makeMemoryImportGateway(
configProvider: { (url: url, token: nil, password: nil) },
responder: { task, request in
if request.method == "migrations.memory.plan" {
task.emitReceiveSuccess(.data(memoryImportError(id: request.id, message: "planning failed")))
return
}
task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: "{}")))
})
let model = OnboardingMemoryImportModel()
await model.startPlanning(gateway: gateway, agentId: "main")
#expect(model.isFailed)
model.dismissFailure()
#expect(!model.pageEligible)
#expect(!model.shouldStartAutomatically)
}
@Test func `partial apply result stays in offer with an inline error`() async throws {
let url = try #require(URL(string: "ws://memory.test"))
let gateway = makeMemoryImportGateway(
configProvider: { (url: url, token: nil, password: nil) },
responder: { task, request in
let payload = switch request.method {
case "migrations.memory.plan": memoryImportOfferPlanPayload
case "migrations.memory.apply": memoryImportApplyPayload(
providerId: request.providerId ?? "unknown",
migrated: 1,
errors: request.providerId == "claude" ? 1 : 0)
default: "{}"
}
task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload)))
})
let model = OnboardingMemoryImportModel()
await model.startPlanning(gateway: gateway, agentId: "main")
model.setSelected(false, providerId: "codex")
await model.importSelected(gateway: gateway)
let claude = try #require(model.providers.first { $0.providerId == "claude" })
#expect(claude.result?.migrated == 1)
#expect(claude.result?.errors == 1)
#expect(claude.inlineError == "1 memory could not be imported.")
#expect(!claude.selected)
#expect(claude.requiresReplan)
model.setSelected(true, providerId: "codex")
await model.importSelected(gateway: gateway)
let retainedClaude = try #require(model.providers.first { $0.providerId == "claude" })
let codex = try #require(model.providers.first { $0.providerId == "codex" })
#expect(retainedClaude.inlineError == "1 memory could not be imported.")
#expect(codex.result?.migrated == 1)
#expect(model.hasReplanRequired)
await model.startPlanning(gateway: gateway, agentId: "main")
let refreshedClaude = try #require(model.providers.first { $0.providerId == "claude" })
let refreshedCodex = try #require(model.providers.first { $0.providerId == "codex" })
#expect(refreshedClaude.result?.migrated == 1)
#expect(refreshedClaude.selected)
#expect(refreshedCodex.result?.migrated == 1)
#expect(!refreshedCodex.selected)
model.setSelected(false, providerId: "claude")
#expect(model.providers.first { $0.providerId == "claude" }?.selected == false)
}
@Test func `stale lease apply response exits applying without accepting results`() async throws {
let url = try #require(URL(string: "ws://memory.test"))
let config = MemoryImportGatewayConfig(url: url, token: "first")
let gate = MemoryImportRequestGate()
let gateway = makeMemoryImportGateway(
configProvider: { config.snapshot() },
responder: { task, request in
if request.method == "migrations.memory.apply" {
await gate.wait()
task.emitReceiveSuccess(.data(memoryImportOK(
id: request.id,
payload: memoryImportApplyPayload(providerId: request.providerId ?? "unknown", migrated: 1))))
return
}
let payload = request.method == "migrations.memory.plan" ? memoryImportOfferPlanPayload : "{}"
task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload)))
})
let model = OnboardingMemoryImportModel()
await model.startPlanning(gateway: gateway, agentId: "main")
let applying = Task { await model.importSelected(gateway: gateway) }
await gate.waitUntilStarted()
config.setToken("replacement")
await gate.release()
await applying.value
#expect(model.isFailed)
#expect(!model.isApplying)
#expect(model.results.isEmpty)
}
}
@@ -101,97 +101,27 @@ struct OnboardingViewSmokeTests {
#expect(scrollView.documentView != nil)
}
@Test func `local page order includes memory import only while eligible`() {
let configuredOrder = OnboardingView.pageOrder(
@Test func `configured flows end at AI setup and hand off to the dashboard`() {
// Everything after working inference (memory import, permissions,
// channels, hatch) belongs to the dashboard custodian onboarding.
#expect(OnboardingView.pageOrder(
for: .local,
requiresCLIInstall: false,
memoryImportEligible: true)
let freshOrder = OnboardingView.pageOrder(
requiresCLIInstall: true) == [0, 1, 2, 3])
#expect(OnboardingView.pageOrder(
for: .local,
requiresCLIInstall: true,
memoryImportEligible: true)
let resolvedEmptyOrder = OnboardingView.pageOrder(
for: .local,
requiresCLIInstall: false,
memoryImportEligible: false)
#expect(configuredOrder == [0, 1, 3, 4, 5, 9])
#expect(freshOrder == [0, 1, 2, 3, 4, 5, 9])
#expect(resolvedEmptyOrder == [0, 1, 3, 5, 9])
#expect(!configuredOrder.contains(7))
#expect(!configuredOrder.contains(8))
requiresCLIInstall: false) == [0, 1, 3])
#expect(OnboardingView.pageOrder(
for: .remote,
requiresCLIInstall: true) == [0, 1, 2, 3])
#expect(OnboardingView.pageOrder(
for: .remote,
requiresCLIInstall: false) == [0, 1, 3])
}
@Test func `remote and unconfigured page orders never include memory import`() {
#expect(OnboardingView.pageOrder(
for: .remote,
requiresCLIInstall: true,
memoryImportEligible: true) == [0, 1, 2, 3, 5, 9])
#expect(OnboardingView.pageOrder(
for: .remote,
requiresCLIInstall: false,
memoryImportEligible: true) == [0, 1, 3, 5, 9])
@Test func `set up later keeps the native ready page`() {
#expect(OnboardingView.pageOrder(
for: .unconfigured,
requiresCLIInstall: false,
memoryImportEligible: true) == [0, 1, 9])
}
@Test func `memory page inclusion follows local model eligibility`() {
let withMemory = OnboardingView.pageOrder(
for: .local,
requiresCLIInstall: false,
memoryImportEligible: true)
#expect(OnboardingView.shouldIncludeMemoryImportPage(
for: .local,
modelEligible: true))
#expect(!OnboardingView.shouldIncludeMemoryImportPage(
for: .local,
modelEligible: false))
#expect(!OnboardingView.shouldIncludeMemoryImportPage(
for: .remote,
modelEligible: true))
let withoutMemory = OnboardingView.pageOrder(
for: .local,
requiresCLIInstall: false,
memoryImportEligible: false)
#expect(withMemory.prefix(3) == withoutMemory.prefix(3))
#expect(!withoutMemory.contains(4))
}
@Test func `memory page removal preserves the active logical page`() throws {
let previousOrder = OnboardingView.pageOrder(
for: .local,
requiresCLIInstall: false,
memoryImportEligible: true)
let newOrder = OnboardingView.pageOrder(
for: .local,
requiresCLIInstall: false,
memoryImportEligible: false)
let aiCursor = try #require(previousOrder.firstIndex(of: 3))
let memoryCursor = try #require(previousOrder.firstIndex(of: 4))
let permissionsCursor = try #require(previousOrder.firstIndex(of: 5))
let readyCursor = try #require(previousOrder.firstIndex(of: 9))
let newPermissionsCursor = try #require(newOrder.firstIndex(of: 5))
let newReadyCursor = try #require(newOrder.firstIndex(of: 9))
#expect(OnboardingView.reconciledPageCursor(
currentPage: aiCursor,
previousOrder: previousOrder,
newOrder: newOrder) == aiCursor)
#expect(OnboardingView.reconciledPageCursor(
currentPage: memoryCursor,
previousOrder: previousOrder,
newOrder: newOrder) == newPermissionsCursor)
#expect(OnboardingView.reconciledPageCursor(
currentPage: permissionsCursor,
previousOrder: previousOrder,
newOrder: newOrder) == newPermissionsCursor)
#expect(OnboardingView.reconciledPageCursor(
currentPage: readyCursor,
previousOrder: previousOrder,
newOrder: newOrder) == newReadyCursor)
requiresCLIInstall: false) == [0, 1, 9])
}
@Test func `fresh local setup installs CLI before inference setup`() {
@@ -322,14 +252,13 @@ struct OnboardingViewSmokeTests {
#expect(monitoredPage == view.activePageIndex)
}
@Test func `gateway route reset returns later pages to inference setup`() throws {
@Test func `gateway route reset keeps the AI page blocking until inference verifies`() throws {
let order = OnboardingView.pageOrder(
for: .remote,
requiresCLIInstall: false)
let permissionsCursor = try #require(order.firstIndex(of: 5))
let aiCursor = try #require(order.firstIndex(of: 3))
let resetCursor = OnboardingView.pageCursorAfterGatewayReset(
currentPage: permissionsCursor,
currentPage: order.count - 1,
pageOrder: order,
aiPageIndex: 3)
@@ -0,0 +1,50 @@
import Testing
@testable import OpenClaw
struct TextSummarySupportTests {
@Test func `keeps the last line for plain output`() {
#expect(TextSummarySupport.summarizeLastLine("first\nsecond") == "second")
}
@Test func `returns nil for blank output`() {
#expect(TextSummarySupport.summarizeLastLine(" \n\t\n") == nil)
}
@Test func `truncates long lines`() {
let summary = TextSummarySupport.summarizeLastLine(String(repeating: "x", count: 300))
#expect(summary?.count == 200)
#expect(summary?.hasSuffix("") == true)
}
@Test func `surfaces the error line instead of the Node version banner`() {
let nodeFatal = """
node:internal/modules/cjs/loader:1215
throw err;
^
Error: Cannot find module '/Users/example/dist/index.js'
at Function._resolveFilename (node:internal/modules/cjs/loader:1212:15)
at node:internal/main/run_main_module:36:49 {
code: 'MODULE_NOT_FOUND'
}
Node.js v26.5.1
"""
#expect(TextSummarySupport.summarizeLastLine(nodeFatal)
== "Error: Cannot find module '/Users/example/dist/index.js'")
}
@Test func `falls back to the last real line when no error line precedes the banner`() {
let output = "some diagnostic\nNode.js v26.5.1"
#expect(TextSummarySupport.summarizeLastLine(output) == "some diagnostic")
}
@Test func `banner-only output keeps the banner`() {
#expect(TextSummarySupport.summarizeLastLine("Node.js v26.5.1") == "Node.js v26.5.1")
}
@Test func `does not jump to old error lines without a banner`() {
let output = "Error: transient\nretrying\ndone"
#expect(TextSummarySupport.summarizeLastLine(output) == "done")
}
}
@@ -6,6 +6,9 @@ public enum DashboardRouteMap {
public static let cronJobsPagePath = "/cron"
public static let sessionsPagePath = "/sessions"
public static let devicesSettingsPath = "/settings/devices"
public static let custodianPagePath = "/custodian"
/// Control UI query that renders /custodian with onboarding chrome.
public static let custodianOnboardingSearch = "?onboarding=1"
public static func isValidSameAppPath(_ path: String) -> Bool {
guard path.hasPrefix("/"), !path.hasPrefix("//"),
@@ -19,8 +22,18 @@ public enum DashboardRouteMap {
components.fragment == nil
}
/// A same-app search must stay a plain query: no scheme/host smuggling and
/// no fragment, which the dashboard URL reserves for the auth token.
public static func isValidSameAppSearch(_ search: String) -> Bool {
guard search.hasPrefix("?"), !search.contains("#") else { return false }
var components = URLComponents()
components.percentEncodedQuery = String(search.dropFirst())
return components.percentEncodedQuery != nil
}
public static func dashboardURL(
byAppendingSameAppPath path: String,
search: String? = nil,
to baseURL: URL) -> URL?
{
guard self.isValidSameAppPath(path),
@@ -28,6 +41,10 @@ public enum DashboardRouteMap {
else {
return nil
}
if let search {
guard self.isValidSameAppSearch(search) else { return nil }
components.percentEncodedQuery = String(search.dropFirst())
}
let basePath = components.path.hasSuffix("/") ? components.path : components.path + "/"
components.path = basePath + path.dropFirst()
return components.url
@@ -36,4 +36,27 @@ struct DashboardRouteMapTests {
#expect(url.absoluteString == "http://127.0.0.1:18789/control/settings/channels#token=test-token")
}
@Test func `Dashboard URL carries a same-app search alongside the token fragment`() throws {
let baseURL = try #require(URL(string: "http://127.0.0.1:18789/control/#token=test-token"))
let url = try #require(DashboardRouteMap.dashboardURL(
byAppendingSameAppPath: DashboardRouteMap.custodianPagePath,
search: DashboardRouteMap.custodianOnboardingSearch,
to: baseURL))
#expect(url.absoluteString == "http://127.0.0.1:18789/control/custodian?onboarding=1#token=test-token")
}
@Test(arguments: ["", "onboarding=1", "?onboarding=1#x", "?a=b#frag"])
func `same-app search validation rejects non-query input`(_ search: String) throws {
#expect(!DashboardRouteMap.isValidSameAppSearch(search))
#expect(try DashboardRouteMap.dashboardURL(
byAppendingSameAppPath: DashboardRouteMap.custodianPagePath,
search: search,
to: #require(URL(string: "http://127.0.0.1:18789/control/"))) == nil)
}
@Test func `same-app search validation accepts a plain query`() {
#expect(DashboardRouteMap.isValidSameAppSearch("?onboarding=1"))
}
}
+6 -3
View File
@@ -38,9 +38,12 @@ has no macOS app asset, use the newest one that does, or build from source with
2. Pick **This Mac** for a local Gateway, or connect to a remote Gateway.
3. Wait while the app installs the matching CLI runtime. In local mode it also
installs and starts the Gateway.
4. Establish inference with a live model check. After it passes, OpenClaw
handles the remaining setup.
5. Complete the macOS permission checklist and send the onboarding test message.
4. Establish inference with a live model check. If the app reused a login you
did not want, **Choose a different AI** on the success banner reopens the
picker, including the API-key option.
5. Finish. The app opens the dashboard, where OpenClaw guides the rest of the
setup (memory import, channels, permissions) in one conversation. Grant
macOS permissions any time from **Settings → Permissions**.
If the app reaches an existing Gateway whose default agent has a configured
model, it treats that Gateway as already set up, skips provider onboarding and
+6
View File
@@ -260,6 +260,12 @@ extraction); event-reactive commentary and channel summon/agent-down recovery
(phase 6 PR2); automatic `localModelLean` for weak models; whether existing
users' saved sidebar pins should adopt the OpenClaw entry.
The macOS app now follows the same browser-first principle: native onboarding
ends once inference verifies (install + AI setup pages), and Finish opens the
dashboard at `/custodian?onboarding=1`. The native memory-import and
permissions pages left the first-run flow (Settings → Permissions remains);
deleting the now-unreachable native memory-import module is a follow-up.
## Testing and landing playbook (hard-won; read before phases 4-6)
- **`OPENCLAW_STATE_DIR` does not isolate the Gateway service.** The
+41
View File
@@ -192,6 +192,47 @@ describe("OpenClaw native shell", () => {
expect(navigate).toHaveBeenCalledExactlyOnceWith("channels", undefined);
});
it("carries a native same-app search into navigation", () => {
const navigate = vi.fn();
const shell = document.createElement("openclaw-app-shell") as unknown as ShellNavigationState;
shell.runtime = {
context: {
navigate,
} as unknown as ApplicationContext,
};
const event = new CustomEvent("openclaw:native-navigate", {
cancelable: true,
detail: { path: "/custodian", search: "?onboarding=1" },
});
shell.handleNativeNavigate(event);
expect(event.defaultPrevented).toBe(true);
expect(navigate).toHaveBeenCalledExactlyOnceWith("custodian", { search: "?onboarding=1" });
});
it.each(["#frag-only", "onboarding=1", "?onboarding=1#x"])(
"ignores malformed native search %s and keeps the plain route",
(search) => {
const navigate = vi.fn();
const shell = document.createElement("openclaw-app-shell") as unknown as ShellNavigationState;
shell.runtime = {
context: {
navigate,
} as unknown as ApplicationContext,
};
const event = new CustomEvent("openclaw:native-navigate", {
cancelable: true,
detail: { path: "/custodian", search },
});
shell.handleNativeNavigate(event);
expect(event.defaultPrevented).toBe(true);
expect(navigate).toHaveBeenCalledExactlyOnceWith("custodian", undefined);
},
);
it.each(["https://example.com", "//example.com", "/https://example.com", "/unknown"])(
"leaves invalid native Dashboard path %s unhandled",
(path) => {
+9 -1
View File
@@ -239,7 +239,8 @@ export class ShellChromeOwner {
};
readonly handleNativeNavigate = (event: Event): void => {
const path = (event as CustomEvent<{ path?: unknown }>).detail?.path;
const detail = (event as CustomEvent<{ path?: unknown; search?: unknown }>).detail;
const path = detail?.path;
const schemeCandidate = typeof path === "string" ? path.slice(1) : "";
if (
typeof path !== "string" ||
@@ -255,6 +256,13 @@ export class ShellChromeOwner {
return;
}
event.preventDefault();
// Native callers may request route chrome via a query (e.g. the macOS
// onboarding handoff lands on /custodian?onboarding=1).
const search = detail?.search;
if (typeof search === "string" && search.startsWith("?") && !search.includes("#")) {
this.host.navigate(routeId, { search });
return;
}
this.host.navigate(routeId);
};