mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
85a85515ef
commit
76cb418b6f
+151
-335
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
|
||||
|
||||
@@ -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 dashboard’s 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("Couldn’t 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 dashboard’s 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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user