import Foundation import Observation import OpenClawKit import OSLog private let chatUILogger = Logger(subsystem: "ai.openclaw", category: "OpenClawChatUI") @MainActor @Observable public final class OpenClawChatViewModel { public nonisolated static let defaultModelSelectionID = "__default__" static let maxAttachmentBytes = 5_000_000 static let sessionListFetchLimit = 200 public internal(set) var messages: [OpenClawChatMessage] = [] public var input: String = "" /// Setter is module-internal for the thinking-level extension only. public internal(set) var thinkingLevel: String /// User intent stays stable while `thinkingLevel` follows the selected model's advertised levels. var preferredThinkingLevel: String /// Setter is module-internal for the thinking-level extension only. public internal(set) var thinkingLevelOptions: [OpenClawChatThinkingLevelOption] /// Setter is module-internal for the thinking-level extension only. public internal(set) var showsThinkingPicker = true public private(set) var modelSelectionID: String = "__default__" public private(set) var modelChoices: [OpenClawChatModelChoice] = [] private var modelPickerFavorites: [String] private var modelPickerRecents: [String] /// Setters are module-internal for the sending extension's command catalog. public internal(set) var slashCommands: [OpenClawChatCommandChoice] = [] public internal(set) var isLoadingSlashCommands = false public internal(set) var slashCommandsErrorText: String? public internal(set) var hasLoadedSlashCommands = false @ObservationIgnored var slashFilterCache: SlashFilterCache? struct SlashFilterCache { let query: String let filter: OpenClawChatCommandFilter let result: [OpenClawChatCommandChoice] } private struct DeferredDeliveryIdentity { let activeAgentID: String? let sessionRoutingContract: String? } public private(set) var isLoading = false /// Setters are module-internal for the sending extension only. public internal(set) var isSending = false public internal(set) var isSendingAttachmentDraft = false private var deferredExternalSessionKey: String? private var deferredDeliveryIdentity: DeferredDeliveryIdentity? var isSubmittingDraft = false private var attachmentStagingCount = 0 public private(set) var isAborting = false public var errorText: String? public var attachments: [OpenClawPendingAttachment] = [] /// Setter is module-internal for the health/outbox extension only. public internal(set) var healthOK: Bool = false /// True when this view model owns a gateway-scoped durable text outbox. public var supportsOfflineTextOutbox: Bool { self.outbox != nil } public private(set) var pendingRunCount: Int = 0 private(set) var hasActiveSessionRunWithoutChatSnapshot = false public private(set) var sessionKey: String { didSet { self.syncContextUsageFraction() } } public private(set) var sessionId: String? public private(set) var streamingAssistantText: String? public private(set) var pendingToolCalls: [OpenClawChatPendingToolCall] = [] private(set) var timelineRevision: UInt64 = 0 /// Setter is module-internal for the transcript-cache extension only. public internal(set) var sessions: [OpenClawChatSessionEntry] = [] { didSet { self.syncContextUsageFraction() } } public internal(set) var contextUsageFraction: Double? /// True while the visible transcript came from the offline cache and no /// live history response has replaced it yet (possibly stale). public internal(set) var isShowingCachedTranscript = false /// Guard the cache pre-paint: once a live response applied (even an empty /// one), a slow cache read must never paint stale rows over it. var hasAppliedLiveHistory = false var hasAppliedLiveSessions = false /// Internal for the outbox extension's flush path only. let transport: any OpenClawChatTransport let haptics: OpenClawChatHaptics let transcriptCache: (any OpenClawChatTranscriptCache)? let outbox: (any OpenClawChatCommandOutbox)? @ObservationIgnored private let modelPickerStore: ChatModelPickerStore /// Per-message outbox display state; rows without an entry are normal /// transcript rows. Observable so bubbles update when flush progresses. public internal(set) var outboxStatesByMessageID: [UUID: OpenClawChatOutboxMessageState] = [:] @ObservationIgnored var outboxCommandIDsByMessageID: [UUID: String] = [:] @ObservationIgnored var outboxMessageIDsByCommandID: [String: UUID] = [:] /// Recent canonical keys let the MainActor resolve proof that arrives /// after SQLite cancellation commits but before its UI continuation runs. @ObservationIgnored var canonicalOutboxMessageKeys: [String] = [] @ObservationIgnored var isFlushingOutbox = false @ObservationIgnored var isOutboxFlushRequestedWhileActive = false @ObservationIgnored var cancelingOutboxCommandIDs: Set = [] @ObservationIgnored var outboxPresentationGeneration: UInt64 = 0 @ObservationIgnored var outboxChangesTask: Task? /// Backoff between failed flush attempts; internal so tests can shorten it. @ObservationIgnored var outboxRetryDelaysMs: [UInt64] = [2000, 8000] /// False until restoreOutboxMessages has adopted durable rows for the /// visible session. Until then the in-memory outbox state is blind to /// rows persisted by an earlier process, so the FIFO send gate must /// assume a backlog exists. @ObservationIgnored var hasRestoredOutboxMessages = false @ObservationIgnored nonisolated(unsafe) var outboxRetryTask: Task? /// A command becomes terminally 'failed' after this many send attempts. nonisolated static let maxOutboxSendAttempts = 3 @ObservationIgnored var pendingCacheWriteTask: Task? private(set) var activeAgentId: String? private(set) var sessionRoutingContract: String? var sessionDefaults: OpenClawChatSessionsDefaults? { didSet { self.syncContextUsageFraction() } } var prefersExplicitThinkingLevel: Bool private let onSessionChanged: (@MainActor (String) -> Void)? let onThinkingLevelChanged: (@MainActor @Sendable (String) -> Void)? private let diagnosticsLog: (@MainActor @Sendable (String) -> Void)? private let attachmentOwnerIsActive: @MainActor () -> Bool @ObservationIgnored private nonisolated(unsafe) var eventTask: Task? @ObservationIgnored private nonisolated(unsafe) var bootstrapTask: Task? var runOwnershipGeneration: UInt64 = 0 var latestAppliedRunSnapshotRequestID: UInt64 = 0 private var isApplyingRunSnapshot = false var pendingRuns = Set() { didSet { if self.pendingRuns != oldValue, !self.isApplyingRunSnapshot { self.runOwnershipGeneration &+= 1 } let nextCount = self.pendingRuns.count guard nextCount != self.pendingRunCount else { return } self.pendingRunCount = nextCount self.markTimelineChanged() } } var pendingLocalUserEchoMessageIDsByRunID: [String: UUID] = [:] // Final chat events and durable session-message rows arrive independently. // Keep each provisional final scoped to the run's user turn so a later identical // answer in the same session does not adopt or suppress the wrong row. var runMessageScopesByRunID: [String: RunMessageScope] = [:] var provisionalFinalMessagesByID: [UUID: ProvisionalFinalMessage] = [:] private var sessionGeneration: UInt64 = 0 private var bootstrapGeneration: UInt64 = 0 // A newer same-session history request only invalidates older responses after it applies. // Failed later refreshes must not drop the last successful pending-run history payload. private var lastIssuedHistoryRequestID: UInt64 = 0 private var latestAppliedHistoryRequestID: UInt64 = 0 private var historyMutationGeneration: UInt64 = 0 private var nextSessionsFetchRequestID: UInt64 = 0 private var latestAppliedSessionsFetchRequestID: UInt64 = 0 /// Outbox replay waits for a sessions list from the current connection generation. var sessionMetadataGeneration: UInt64 = 0 var readySessionMetadataGeneration: UInt64? @ObservationIgnored nonisolated(unsafe) var pendingRunOwnerTasks: [String: Task] = [:] var nextPendingRunOwnerArmID: UInt64 = 0 var pendingRunOwnerArmIDs: [String: UInt64] = [:] @ObservationIgnored private nonisolated(unsafe) var activeSessionRunIndicatorTimeoutTask: Task? var pendingRunWaitTimeoutMs: UInt64 = 120_000 var pendingRunUnavailableRetryMs: UInt64 = 30000 var pendingRunTerminalRetryMs: UInt64 = 2000 var pendingRunTerminalHistoryGraceMs: UInt64 = 10000 var pendingRunRefreshDelaysMs: [UInt64] = [ 1500, 4000, 9000, 20000, 45000, ] var pendingRunSteadyRefreshDelayMs: UInt64 = 60000 // Session switches can overlap in-flight picker patches, so stale completions // must compare against the latest request and latest desired value for that session. private var nextModelSelectionRequestID: UInt64 = 0 private var latestModelSelectionRequestIDsByTarget: [ModelPatchTarget: UInt64] = [:] private var lastSuccessfulModelSelectionIDsByTarget: [ModelPatchTarget: String] = [:] /// Rollback and pre-refresh sends need the thinking state from the same successful model patch. var lastSuccessfulModelPatchResultsByTarget: [ModelPatchTarget: OpenClawChatModelPatchResult] = [:] var completedModelPatchTargets: Set = [] private var inFlightModelPatchCountsByTarget: [ModelPatchTarget: Int] = [:] private var modelPatchRevisionsByTarget: [ModelPatchTarget: UInt64] = [:] private var modelPatchWaitersByTarget: [ModelPatchTarget: [CheckedContinuation]] = [:] @ObservationIgnored private var modelPatchTailsByTarget: [ModelPatchTarget: ModelPatchTail] = [:] var nextThinkingSelectionRequestID: UInt64 = 0 var latestThinkingSelectionRequestIDsBySession: [String: UInt64] = [:] var latestThinkingLevelsBySession: [String: String] = [:] private var isCompacting = false private var lastCompactAt: Date? private let compactCooldown: TimeInterval = 60 private enum SessionSwitchIntent { case userInitiated case externalSync } struct ModelPatchTarget: Hashable { let canonicalSessionKey: String let agentID: String? let sessionRoutingContract: String? } private struct ModelSelectionRequest { let id: UInt64 let target: ModelPatchTarget let session: SessionSnapshot let sessionEntryKey: String? let rollbackSelectionID: String let previousRequestID: UInt64? let selectionID: String let modelRef: String? } private struct ModelPatchTail { let requestID: UInt64 let task: Task } private struct BootstrapContext { var id: UInt64 var historyRequest: HistoryRequest var session: SessionSnapshot { self.historyRequest.session } } struct HistoryRequest { var id: UInt64 var session: SessionSnapshot var pendingRunIDs: Set var visibleMessagesByID: [UUID: OpenClawChatMessage] var historyMutationGeneration: UInt64 var runOwnershipGeneration: UInt64 var latestUserTurn: LatestUserTurn? } struct RunHistoryRefreshResult { let applied: Bool let runSnapshotApplied: Bool let supportsInFlightRunState: Bool let hasInFlightRun: Bool let sessionHasActiveRun: Bool static let failed = RunHistoryRefreshResult( applied: false, runSnapshotApplied: false, supportsInFlightRunState: false, hasInFlightRun: false, sessionHasActiveRun: false) } struct LatestUserTurn { var idempotencyKey: String? var refreshKey: String? var occurrence: Int var timestamp: Double? } struct RunMessageScope { var session: SessionSnapshot var latestUserTurn: LatestUserTurn? } struct ProvisionalFinalMessage { var reconciliationKey: String var runId: String? var scope: RunMessageScope } var pendingToolCallsById: [String: OpenClawChatPendingToolCall] = [:] { didSet { guard self.pendingToolCallsById != oldValue else { return } self.pendingToolCalls = self.pendingToolCallsById.values .sorted { ($0.startedAt ?? 0) < ($1.startedAt ?? 0) } self.markTimelineChanged() } } var lastHealthPollAt: Date? public init( sessionKey: String, transport: any OpenClawChatTransport, activeAgentId: String? = nil, sessionRoutingContract: String? = nil, attachmentOwnerIsActive: @escaping @MainActor () -> Bool = { false }, haptics: OpenClawChatHaptics = OpenClawChatHaptics(), transcriptCache: (any OpenClawChatTranscriptCache)? = nil, outbox: (any OpenClawChatCommandOutbox)? = nil, modelPickerStore: ChatModelPickerStore = ChatModelPickerStore(), initialThinkingLevel: String? = nil, onSessionChanged: (@MainActor (String) -> Void)? = nil, onThinkingLevelChanged: (@MainActor @Sendable (String) -> Void)? = nil, diagnosticsLog: (@MainActor @Sendable (String) -> Void)? = nil) { self.sessionKey = sessionKey self.transport = transport self.haptics = haptics self.transcriptCache = transcriptCache self.modelPickerStore = modelPickerStore self.modelPickerFavorites = modelPickerStore.favorites self.modelPickerRecents = modelPickerStore.recents self.outbox = outbox let normalizedAgentId = activeAgentId?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() self.activeAgentId = normalizedAgentId?.isEmpty == false ? normalizedAgentId : nil let normalizedRoutingContract = sessionRoutingContract? .trimmingCharacters(in: .whitespacesAndNewlines) self.sessionRoutingContract = normalizedRoutingContract?.isEmpty == false ? normalizedRoutingContract : nil let normalizedThinkingLevel = Self.normalizedThinkingLevel(initialThinkingLevel) let initialResolvedThinkingLevel = normalizedThinkingLevel ?? "off" self.thinkingLevel = initialResolvedThinkingLevel self.preferredThinkingLevel = initialResolvedThinkingLevel self.thinkingLevelOptions = Self.withCurrentThinkingOption( Self.baseThinkingLevelOptions, current: initialResolvedThinkingLevel) self.prefersExplicitThinkingLevel = normalizedThinkingLevel != nil self.onSessionChanged = onSessionChanged self.onThinkingLevelChanged = onThinkingLevelChanged self.diagnosticsLog = diagnosticsLog self.attachmentOwnerIsActive = attachmentOwnerIsActive let transport = self.transport self.eventTask = Task { [weak self, transport] in let stream = transport.events() for await evt in stream { if Task.isCancelled { return } await MainActor.run { [weak self] in self?.handleTransportEvent(evt) } } } if let outbox = self.outbox { let changes = outbox.changes() self.outboxChangesTask = Task { [weak self, changes] in for await change in changes { guard !Task.isCancelled else { return } self?.handleOutboxChange(change) } } } } deinit { self.eventTask?.cancel() self.bootstrapTask?.cancel() self.outboxRetryTask?.cancel() self.outboxChangesTask?.cancel() self.activeSessionRunIndicatorTimeoutTask?.cancel() for (_, task) in self.pendingRunOwnerTasks { task.cancel() } } public func load() { self.startBootstrap() } public func refresh() { self.startBootstrap() } public var modelPickerSections: ChatModelPickerSections { ChatModelPickerStore.sections( choices: self.modelChoices, favorites: self.modelPickerFavorites, recents: self.modelPickerRecents) } public var isSelectedModelPinned: Bool { self.modelSelectionID != Self.defaultModelSelectionID && self.modelPickerFavorites.contains(self.modelSelectionID) } public func toggleSelectedModelPinned() { guard self.modelSelectionID != Self.defaultModelSelectionID else { return } self.modelPickerStore.toggleFavorite(self.modelSelectionID) self.modelPickerFavorites = self.modelPickerStore.favorites } public func resumeFromForeground() { Task { await self.refreshRunStateAfterForeground() } } public func abort() { Task { await self.performAbort() } } public func deleteSession(_ sessionKey: String) { Task { do { try await self.transport.deleteSession(key: sessionKey) } catch { self.errorText = error.localizedDescription return } self.sessions.removeAll { $0.key == sessionKey } if self.matchesCurrentSessionKey(incoming: sessionKey, current: self.sessionKey) { // The active transcript just disappeared server-side; fall // back to the main session instead of a dead key. let fallback = self.resolvedMainSessionKey if fallback != self.sessionKey { self.applySessionSwitch(to: fallback, intent: .userInitiated) } else { // Deleting the active main session: the key stays the // address, so clear local state and re-bootstrap in place. self.advanceSessionGeneration() self.clearSessionOwnedState() self.errorText = nil self.startBootstrap() } } await self.fetchSessions(limit: nil, sessionSnapshot: self.currentSessionSnapshot()) } } public func switchSession(to sessionKey: String) { self.applySessionSwitch(to: sessionKey, intent: .userInitiated) } public func syncSession(to sessionKey: String) { self.applySessionSwitch(to: sessionKey, intent: .externalSync) } public func syncActiveAgentId(_ agentId: String?) { self.syncDeliveryIdentity( activeAgentId: agentId, sessionRoutingContract: self.deferredDeliveryIdentity?.sessionRoutingContract ?? self.sessionRoutingContract) } public func syncSessionRoutingContract(_ contract: String?) { self.syncDeliveryIdentity( activeAgentId: self.deferredDeliveryIdentity?.activeAgentID ?? self.activeAgentId, sessionRoutingContract: contract) } /// Updates the alias owner and its gateway routing contract as one /// identity change so an intermediate bootstrap cannot win either value. public func syncDeliveryIdentity( activeAgentId agentId: String?, sessionRoutingContract contract: String?) { let normalized = agentId?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let nextAgentId = normalized?.isEmpty == false ? normalized : nil let normalizedContract = contract?.trimmingCharacters(in: .whitespacesAndNewlines) let nextContract = normalizedContract?.isEmpty == false ? normalizedContract : nil let agentChanged = self.activeAgentId != nextAgentId let contractChanged = self.sessionRoutingContract != nextContract guard agentChanged || contractChanged else { if self.blocksAttachmentOwnerChange { self.deferredDeliveryIdentity = nil } return } if self.blocksAttachmentOwnerChange { self.deferredDeliveryIdentity = DeferredDeliveryIdentity( activeAgentID: nextAgentId, sessionRoutingContract: nextContract) return } self.deferredDeliveryIdentity = nil // A scoped key can be the main alias under either side of a contract // change. Check both or stale transcript state can survive the switch. let contractRoutingChanged = contractChanged && (usesMutableContractRouting(for: sessionRoutingContract) || self.usesMutableContractRouting(for: nextContract)) self.activeAgentId = nextAgentId self.sessionRoutingContract = nextContract let bootstrapIdentityChanged = (agentChanged && self.usesMutableAgentRouting) || contractRoutingChanged guard bootstrapIdentityChanged else { if contractChanged, self.healthOK { flushOutboxIfNeeded() } return } // Restart when this key depends on a changed routing value so cleared // state cannot remain stuck or cross session owners. self.advanceSessionGeneration() self.clearSessionOwnedState() self.startBootstrap() } public func selectThinkingLevel(_ level: String) { Task { await self.performSelectThinkingLevel(level) } } public func selectModel(_ selectionID: String) { guard let request = self.reserveModelSelection(selectionID) else { return } let previousTail = self.modelPatchTailsByTarget[request.target]?.task let task = Task { [weak self] in await previousTail?.value guard let self else { return } await self.performSelectModel(request) self.finishModelPatchTail(requestID: request.id, target: request.target) } self.modelPatchTailsByTarget[request.target] = ModelPatchTail( requestID: request.id, task: task) } var resolvedMainSessionKey: String { let trimmed = self.sessionDefaults?.mainSessionKey? .trimmingCharacters(in: .whitespacesAndNewlines) return (trimmed?.isEmpty == false ? trimmed : nil) ?? "main" } private var usesMutableAgentRouting: Bool { Self.agentID(fromSessionKey: self.sessionKey) == nil } private func usesMutableContractRouting(for contract: String?) -> Bool { self.usesMutableContractRouting(sessionKey: self.sessionKey, contract: contract) } func usesMutableContractRouting(sessionKey: String, contract: String?) -> Bool { if Self.agentID(fromSessionKey: sessionKey) == nil { return true } let parts = sessionKey .trimmingCharacters(in: .whitespacesAndNewlines) .split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false) guard parts.count == 3 else { return false } let normalizedSessionKey = parts[2].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let resolvedMainParts = self.resolvedMainSessionKey .trimmingCharacters(in: .whitespacesAndNewlines) .split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false) let normalizedMainSessionKey = String(resolvedMainParts.last ?? "") .trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let contractMainKey = OpenClawChatSessionRoutingContract.parse(contract)?.mainKey ?? "" return normalizedSessionKey == "global" || normalizedSessionKey == "main" || normalizedSessionKey == normalizedMainSessionKey || normalizedSessionKey == contractMainKey } public var showsModelPicker: Bool { !self.modelChoices.isEmpty } public var defaultModelLabel: String { guard let defaultModelID = normalizedModelSelectionID(sessionDefaults?.model) else { return "Default" } return "Default: \(self.modelLabel(for: defaultModelID))" } static let baseThinkingLevelOptions: [OpenClawChatThinkingLevelOption] = [ OpenClawChatThinkingLevelOption(id: "off", label: "off"), OpenClawChatThinkingLevelOption(id: "minimal", label: "minimal"), OpenClawChatThinkingLevelOption(id: "low", label: "low"), OpenClawChatThinkingLevelOption(id: "medium", label: "medium"), OpenClawChatThinkingLevelOption(id: "high", label: "high"), ] public func addAttachments(urls: [URL]) { self.beginAttachmentStaging() Task { defer { self.endAttachmentStaging() } await self.loadAttachments(urls: urls) } } public func addImageAttachment(data: Data, fileName: String, mimeType: String) { self.beginAttachmentStaging() Task { defer { self.endAttachmentStaging() } await self.addImageAttachment(url: nil, data: data, fileName: fileName, mimeType: mimeType) } } public func removeAttachment(_ id: OpenClawPendingAttachment.ID) { self.attachments.removeAll { $0.id == id } self.applyDeferredExternalStateIfReady() } public var canSend: Bool { !self.isSubmittingDraft && !self.isSending && !self.hasBlockingRunActivity && self.hasDraftToSend } public var hasDraftToSend: Bool { let trimmed = self.input.trimmingCharacters(in: .whitespacesAndNewlines) return !trimmed.isEmpty || !self.attachments.isEmpty } /// True while replacing this model could move an attachment across chats. public var isAttachmentOwnerPinned: Bool { self.blocksAttachmentOwnerChange } private var blocksAttachmentOwnerChange: Bool { self.attachmentOwnerIsActive() || self.isSendingAttachmentDraft || self.attachmentStagingCount > 0 || !self.attachments.isEmpty } var hasBlockingRunActivity: Bool { self.pendingRunCount > 0 || self.hasActiveSessionRunWithoutChatSnapshot } /// Applies external owner changes once recording or staging releases them. public func attachmentOwnerActivityChanged() { self.applyDeferredExternalStateIfReady() } /// File reads and image processing suspend before the attachment exists. /// Keep their original chat owner pinned until staging succeeds or fails. func beginAttachmentStaging() { self.attachmentStagingCount += 1 } func endAttachmentStaging() { precondition(self.attachmentStagingCount > 0) self.attachmentStagingCount -= 1 self.applyDeferredExternalStateIfReady() } } extension OpenClawChatViewModel { // MARK: - Internals func markTimelineChanged() { self.timelineRevision &+= 1 } func appendMessage(_ message: OpenClawChatMessage) { self.messages.append(message) self.markTimelineChanged() } func removeMessage(id: UUID) { let previousCount = self.messages.count self.messages.removeAll { $0.id == id } if self.messages.count != previousCount { self.markTimelineChanged() } } func updateStreamingAssistantText(_ text: String?) { guard self.streamingAssistantText != text else { return } self.streamingAssistantText = text self.markTimelineChanged() } func updateActiveSessionRunWithoutChatSnapshot(_ active: Bool) { guard self.hasActiveSessionRunWithoutChatSnapshot != active else { return } self.hasActiveSessionRunWithoutChatSnapshot = active if active { self.armActiveSessionRunIndicatorTimeout() } else { self.activeSessionRunIndicatorTimeoutTask?.cancel() self.activeSessionRunIndicatorTimeoutTask = nil } self.markTimelineChanged() } private func armActiveSessionRunIndicatorTimeout() { self.activeSessionRunIndicatorTimeoutTask?.cancel() let timeoutMs = self.pendingRunWaitTimeoutMs self.activeSessionRunIndicatorTimeoutTask = Task { [weak self] in do { try await Task.sleep(nanoseconds: timeoutMs * 1_000_000) } catch { return } await MainActor.run { self?.updateActiveSessionRunWithoutChatSnapshot(false) } } } func clearActiveSessionRunIndicatorIfLatestUserAnswered() { guard self.hasActiveSessionRunWithoutChatSnapshot, !Self.hasUnansweredLatestUser(in: self.messages) else { return } self.updateActiveSessionRunWithoutChatSnapshot(false) } func logDiagnostic(_ message: String) { self.diagnosticsLog?(message) } func currentSessionSnapshot() -> SessionSnapshot { SessionSnapshot( key: self.sessionKey, generation: self.sessionGeneration, agentID: self.activeAgentId, deliveryAgentID: Self.agentID(fromSessionKey: self.sessionKey) ?? self.activeAgentId, sessionRoutingContract: self.sessionRoutingContract) } func isCurrentSession(_ snapshot: SessionSnapshot) -> Bool { let contractSensitive = self.usesMutableContractRouting(for: snapshot.sessionRoutingContract) || self.usesMutableContractRouting(for: self.sessionRoutingContract) return self.sessionKey == snapshot.key && self.sessionGeneration == snapshot.generation && (!self.usesMutableAgentRouting || self.activeAgentId == snapshot.agentID) && (!contractSensitive || self.sessionRoutingContract == snapshot.sessionRoutingContract) } private func isCurrentBootstrap(_ context: BootstrapContext) -> Bool { self.bootstrapGeneration == context.id && self.isCurrentSession(context.session) } private func canApplyHistory(_ request: HistoryRequest) -> Bool { request.id >= self.latestAppliedHistoryRequestID && self.isCurrentSession(request.session) } private func advanceSessionGeneration() { self.sessionGeneration &+= 1 } func invalidateRunSnapshots() { self.runOwnershipGeneration &+= 1 } func invalidateHistorySnapshots() { self.historyMutationGeneration &+= 1 } func beginHistoryRequest( for sessionSnapshot: SessionSnapshot? = nil, captureLatestUserTurn: Bool = true) -> HistoryRequest { self.lastIssuedHistoryRequestID &+= 1 return HistoryRequest( id: self.lastIssuedHistoryRequestID, session: sessionSnapshot ?? self.currentSessionSnapshot(), pendingRunIDs: self.pendingRuns, visibleMessagesByID: Dictionary(uniqueKeysWithValues: self.messages.map { ($0.id, $0) }), historyMutationGeneration: self.historyMutationGeneration, runOwnershipGeneration: self.runOwnershipGeneration, latestUserTurn: captureLatestUserTurn ? Self.latestUserTurn(in: self.messages) : nil) } private func markHistoryRequestApplied(_ request: HistoryRequest) { self.latestAppliedHistoryRequestID = max(self.latestAppliedHistoryRequestID, request.id) } @discardableResult func applyHistoryPayload( _ payload: OpenClawChatHistoryPayload, for request: HistoryRequest, preservingOptimisticLocalMessages: Bool, syncThinkingOptions: Bool = false) -> Bool { guard self.canApplyHistory(request) else { return false } let incoming = adoptingProvisionalFinalMessageIDs( in: Self.decodeMessages(payload.messages ?? [])) let unmatchedProvisionalFinalIDs = Set(provisionalFinalMessagesMissing(from: incoming).map(\.id)) var retainedMessageIDs = unmatchedProvisionalFinalIDs if request.historyMutationGeneration != self.historyMutationGeneration { for message in self.messages where request.visibleMessagesByID[message.id] != message { let isMatchedProvisional = self.provisionalFinalMessagesByID[message.id] != nil && !unmatchedProvisionalFinalIDs.contains(message.id) if !isMatchedProvisional { retainedMessageIDs.insert(message.id) } } } // Durable outbox rows remain authoritative until canonical history // confirms their idempotency key. Keep their bubbles through lagging // snapshots, including across app relaunches and session switches. retainedMessageIDs.formUnion(self.outboxCommandIDsByMessageID.keys) var nextMessages = if preservingOptimisticLocalMessages { Self.reconcileRunRefreshMessages( previous: self.messages, incoming: incoming, pendingLocalUserEchoIDs: Set(self.pendingLocalUserEchoMessageIDsByRunID.values)) } else { Self.reconcileMessageIDs(previous: self.messages, incoming: incoming) } let reconciledMessageIDs = Set(nextMessages.map(\.id)) nextMessages.append(contentsOf: self.messages.filter { message in retainedMessageIDs.contains(message.id) && !reconciledMessageIDs.contains(message.id) }) nextMessages = Self.dedupeMessages(nextMessages) replaceMessages(nextMessages) confirmOutboxCommands(in: incoming) prunePendingLocalUserEchoMessageIDs() clearProvisionalFinalMarkersAdoptedByHistory(incoming) pruneProvisionalFinalMessages() pruneRunMessageScopes() self.rescopeRunsAdoptedAfterHistoryRequest(request) self.sessionId = payload.sessionId self.applyInFlightRunSnapshot(payload.inFlightRun, for: request) // Incomplete refreshes can arrive before durable assistant history. // The latest visible user turn must survive answered before it can reject older replies. let canInvalidateOlderHistory = if let latestUserTurn = request.latestUserTurn { Self.hasAnsweredUser(latestUserTurn, in: self.messages) } else { !Self.hasUnansweredLatestUser(in: self.messages) } if canInvalidateOlderHistory { self.markHistoryRequestApplied(request) } self.clearActiveSessionRunIndicatorIfLatestUserAnswered() let appliedThinkingLevel = !self.prefersExplicitThinkingLevel ? Self.normalizedThinkingLevel(payload.thinkingLevel) : nil if let level = appliedThinkingLevel { self.preferredThinkingLevel = level self.thinkingLevel = level } if syncThinkingOptions || appliedThinkingLevel != nil { syncThinkingLevelOptions() } // Live history is the source of truth: it clears the cached marker and // is written through so the next cold open pre-paints current rows. self.hasAppliedLiveHistory = true self.isShowingCachedTranscript = false // An empty post-send refresh is incomplete by contract: reconciliation // preserves the visible transcript, so preserve its last canonical cache too. if !preservingOptimisticLocalMessages || !incoming.isEmpty { // Persist the reconciled transcript, including durable outbox // rows retained while canonical history catches up. persistTranscriptToCache( sessionKey: request.session.key, agentID: request.session.agentID, messages: nextMessages, canonicalMessageIdempotencyKeys: Set(incoming.compactMap(\.idempotencyKey))) } // Wholesale history replacement drops local-only queued bubbles; // re-adopt or re-append them from the durable outbox. restoreOutboxMessages(session: request.session) self.applyDeferredExternalStateIfReady() return true } private func provisionalFinalMessagesMissing( from incoming: [OpenClawChatMessage]) -> [OpenClawChatMessage] { let incomingRunIds = Set(incoming.compactMap { Self.normalizedIdempotencyKey($0.idempotencyKey) }) return self.messages.filter { message in guard let provisional = provisionalFinalMessagesByID[message.id] else { return false } if let runId = provisional.runId, incomingRunIds.contains(runId) { return false } guard Self.containsUserTurn(provisional.scope.latestUserTurn, in: incoming) else { return true } let searchRange = Self.messageRange(after: provisional.scope.latestUserTurn, in: incoming) return !incoming[searchRange].contains { incomingMessage in Self.finalMessageReconciliationKey(for: incomingMessage) == provisional.reconciliationKey } } } private func rescopeRunsAdoptedAfterHistoryRequest(_ request: HistoryRequest) { for runId in self.pendingRuns { let scope = self.runMessageScopesByRunID[runId] if !request.pendingRunIDs.contains(runId) || scope?.latestUserTurn == nil { self.runMessageScopesByRunID[runId] = currentRunMessageScope() } } } private func applyInFlightRunSnapshot( _ snapshot: OpenClawChatInFlightRun?, for request: HistoryRequest) { guard request.runOwnershipGeneration == self.runOwnershipGeneration, request.id >= self.latestAppliedRunSnapshotRequestID else { return } self.latestAppliedRunSnapshotRequestID = request.id guard let snapshot, let runId = Self.normalizedRunID(snapshot.runId) else { return } self.isApplyingRunSnapshot = true defer { self.isApplyingRunSnapshot = false } self.updateActiveSessionRunWithoutChatSnapshot(false) self.adoptRun(runId: runId, bufferedText: snapshot.text) } func adoptRun(runId: String, bufferedText: String) { let canonicalPendingRuns = Set([runId]) if self.pendingRuns != canonicalPendingRuns { // Gateway snapshots and live deltas are canonical for this session. // Replace stale local ownership so only that run consumes later events. self.clearPendingRuns(reason: nil) self.pendingRuns.insert(runId) self.pendingToolCallsById = [:] self.updateStreamingAssistantText(nil) } if self.runMessageScopesByRunID[runId] == nil { self.runMessageScopesByRunID[runId] = currentRunMessageScope() } if self.pendingRunOwnerArmIDs[runId] == nil { self.armPendingRunOwner(runId: runId) } if !bufferedText.isEmpty { self.updateStreamingAssistantText(bufferedText) } self.logDiagnostic( "chat.ui adopted in-flight run sessionKey=\(self.sessionKey) " + "runId=\(runId) bufferedTextLen=\(bufferedText.count)") } private func startBootstrap(sessionKey requestedSessionKey: String? = nil) { let sessionKey = requestedSessionKey ?? self.sessionKey guard sessionKey == self.sessionKey else { return } self.bootstrapGeneration &+= 1 self.bootstrapTask?.cancel() self.isLoading = true self.errorText = nil self.invalidateSessionMetadataReadiness() self.healthOK = false self.clearPendingRuns(reason: nil) self.pendingToolCallsById = [:] self.updateStreamingAssistantText(nil) self.updateActiveSessionRunWithoutChatSnapshot(false) self.sessionId = nil let historyRequest = self.beginHistoryRequest(captureLatestUserTurn: requestedSessionKey == nil) let context = BootstrapContext( id: bootstrapGeneration, historyRequest: historyRequest) paintFromCacheIfNeeded(session: context.session) restoreOutboxMessages(session: context.session) self.bootstrapTask = Task { [weak self] in guard let self else { return } await self.bootstrap(context: context) } } private func bootstrap(context: BootstrapContext) async { guard self.isCurrentBootstrap(context) else { return } defer { if self.isCurrentBootstrap(context) { self.isLoading = false } } do { await self.syncActiveSessionSubscription(startingWith: context.session.key) guard self.isCurrentBootstrap(context) else { return } let payload = try await transport.requestHistory(sessionKey: context.session.key) guard self.isCurrentBootstrap(context) else { return } _ = self.applyHistoryPayload( payload, for: context.historyRequest, preservingOptimisticLocalMessages: false, syncThinkingOptions: true) await pollHealthIfNeeded( force: true, sessionSnapshot: context.session, refreshSessionsOnReconnect: false) guard self.isCurrentBootstrap(context) else { return } await self.fetchSessions(limit: 50, sessionSnapshot: context.session) guard self.isCurrentBootstrap(context) else { return } await self.fetchModels(sessionSnapshot: context.session) guard self.isCurrentBootstrap(context) else { return } self.errorText = nil } catch { guard self.isCurrentBootstrap(context) else { return } self.errorText = error.localizedDescription chatUILogger.error("bootstrap failed \(error.localizedDescription, privacy: .public)") } } private func syncActiveSessionSubscription(startingWith sessionKey: String) async { var nextSessionKey = sessionKey while true { do { // Subscribe requests are gateway side effects. If a stale request finishes // after a newer switch, immediately reassert the latest visible session. try await self.transport.setActiveSessionKey(nextSessionKey) } catch { let currentSessionKey = self.sessionKey guard currentSessionKey != nextSessionKey else { // Best-effort only; history/send/health still work without push events. return } nextSessionKey = currentSessionKey continue } let currentSessionKey = self.sessionKey guard currentSessionKey != nextSessionKey else { return } nextSessionKey = currentSessionKey } } private func refreshRunStateAfterForeground() async { let context = self.beginHistoryRequest() self.logDiagnostic( "chat.ui foreground refresh sessionKey=\(context.session.key) " + "pending=\(self.pendingRunCount)") let refresh = await refreshHistoryAfterRun(historyRequest: context) guard self.isCurrentSession(context.session) else { return } if refresh.applied, refresh.runSnapshotApplied, context.runOwnershipGeneration == self.runOwnershipGeneration, !self.isSending, refresh.supportsInFlightRunState, !refresh.hasInFlightRun { if refresh.sessionHasActiveRun, Self.hasUnansweredLatestUser(in: self.messages) { self.pendingToolCallsById = [:] self.updateStreamingAssistantText(nil) // Keep a known run ID authoritative so its stream and terminal // events still route here. Synthesize activity only after the // client has no run identity to preserve. self.updateActiveSessionRunWithoutChatSnapshot(self.pendingRuns.isEmpty) } else { self.updateActiveSessionRunWithoutChatSnapshot(false) self.clearPendingRuns( reason: nil, hapticEvent: self.assistantHapticEventAfterLatestUser()) self.pendingToolCallsById = [:] self.updateStreamingAssistantText(nil) } } await pollHealthIfNeeded(force: true, sessionSnapshot: context.session) } private func performAbort() async { guard !self.pendingRuns.isEmpty else { return } guard !self.isAborting else { return } self.isAborting = true defer { self.isAborting = false } let runIds = Array(pendingRuns) for runId in runIds { do { try await self.transport.abortRun(sessionKey: self.sessionKey, runId: runId) } catch { // Best-effort. } } } func fetchSessions(limit: Int?, sessionSnapshot: SessionSnapshot? = nil) async { self.nextSessionsFetchRequestID &+= 1 let sessionsFetchRequestID = self.nextSessionsFetchRequestID let session = sessionSnapshot ?? self.currentSessionSnapshot() let target = self.modelPatchTarget( sessionKey: session.key, canonicalSessionKey: self.isCurrentSession(session) ? self.currentSessionEntry()?.key : nil, agentID: session.deliveryAgentID, sessionRoutingContract: session.sessionRoutingContract) var preservesOverlappingModelPatch = false while true { await self.waitForPendingModelPatches(for: target) if let sessionSnapshot, !self.isCurrentSession(sessionSnapshot) { return } let metadataGeneration = self.sessionMetadataGeneration let modelPatchRevision = self.modelPatchRevisionsByTarget[target, default: 0] let res: OpenClawChatSessionsListResponse do { res = try await self.transport.listSessions(limit: limit, search: nil, archived: false) } catch { if self.outbox != nil, self.healthOK, !self.hasCurrentSessionMetadata { self.applyTransportHealth(false) } return } if let sessionSnapshot, !self.isCurrentSession(sessionSnapshot) { return } guard sessionsFetchRequestID > self.latestAppliedSessionsFetchRequestID else { return } // A list that straddles a patch or reconnect is stale. Retry in this // owner so bootstrap cannot discard its only authoritative refresh. guard metadataGeneration == self.sessionMetadataGeneration else { preservesOverlappingModelPatch = false continue } guard modelPatchRevision == self.modelPatchRevisionsByTarget[target, default: 0], self.inFlightModelPatchCountsByTarget[target] == nil else { preservesOverlappingModelPatch = true continue } self.latestAppliedSessionsFetchRequestID = sessionsFetchRequestID let organized = OpenClawChatSessionListOrganizer.organize(res.sessions) self.sessions = organized self.sessionDefaults = res.defaults if preservesOverlappingModelPatch, let selectionID = self.lastSuccessfulModelSelectionIDsByTarget[target] { // A post-patch list retry may still carry the pre-patch row. // Preserve only the route whose patch overlapped this fetch. let patchResult = self.lastSuccessfulModelPatchResultsByTarget[target] self.applySuccessfulModelSelection( selectionID, target: target, sessionEntryKey: patchResult?.key ?? target.canonicalSessionKey, syncSelection: false, patchResult: patchResult) } self.hasAppliedLiveSessions = true self.syncSelectedModel() self.syncThinkingLevelOptions() self.persistSessionsToCache(organized) self.readySessionMetadataGeneration = metadataGeneration if self.healthOK { self.flushOutboxIfNeeded() } return } } func invalidateSessionMetadataReadiness() { self.sessionMetadataGeneration &+= 1 self.readySessionMetadataGeneration = nil } var hasCurrentSessionMetadata: Bool { self.readySessionMetadataGeneration == self.sessionMetadataGeneration } private func fetchModels(sessionSnapshot: SessionSnapshot? = nil) async { do { let modelChoices = try await transport.listModels() if let sessionSnapshot, !self.isCurrentSession(sessionSnapshot) { return } self.modelChoices = modelChoices self.syncSelectedModel() syncThinkingLevelOptions() } catch { // Best-effort. } } private func applySessionSwitch(to sessionKey: String, intent: SessionSwitchIntent) { let next = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) guard !next.isEmpty else { return } guard next != self.sessionKey else { if intent == .externalSync { self.deferredExternalSessionKey = nil } return } if self.blocksAttachmentOwnerChange { switch intent { case .externalSync: self.deferredExternalSessionKey = next case .userInitiated: self.errorText = String( localized: "Remove attachments or wait for delivery to resolve before switching chats.") } return } self.deferredExternalSessionKey = nil self.advanceSessionGeneration() self.sessionKey = next if intent == .userInitiated { self.onSessionChanged?(next) } self.clearSessionOwnedState() self.startBootstrap(sessionKey: next) } func applyDeferredExternalStateIfReady() { guard !self.blocksAttachmentOwnerChange else { return } if let identity = deferredDeliveryIdentity { self.deferredDeliveryIdentity = nil self.syncDeliveryIdentity( activeAgentId: identity.activeAgentID, sessionRoutingContract: identity.sessionRoutingContract) } guard let sessionKey = deferredExternalSessionKey else { return } self.deferredExternalSessionKey = nil self.applySessionSwitch(to: sessionKey, intent: .externalSync) } func performStartNewSession(worktree: Bool) async { guard !self.blocksAttachmentOwnerChange else { self.errorText = String( localized: "Remove attachments or wait for delivery to resolve before starting a new chat.") return } let requested = self.generatedNewSessionKey() let parentSessionKey = self.sessionKey let next: String do { let created = try await transport.createSession( key: requested, label: nil, parentSessionKey: parentSessionKey, worktree: worktree ? true : nil) let createdKey = created.key.trimmingCharacters(in: .whitespacesAndNewlines) next = createdKey.isEmpty ? requested : createdKey } catch { if Self.isUnsupportedCreateSessionError(error) { chatUILogger.info("sessions.create unsupported; falling back to sessions.reset") await self.performReset() return } chatUILogger.error("sessions.create failed \(error.localizedDescription, privacy: .public)") self.errorText = error.localizedDescription return } guard !self.blocksAttachmentOwnerChange else { self.errorText = String( localized: "Remove attachments or wait for delivery to resolve before starting a new chat.") return } self.advanceSessionGeneration() self.sessionKey = next self.onSessionChanged?(next) self.clearSessionOwnedState() self.errorText = nil self.startBootstrap() } /// Clears state owned by the current session/agent before a new identity can consume events. private func clearSessionOwnedState() { self.modelSelectionID = Self.defaultModelSelectionID replaceMessages([]) self.isShowingCachedTranscript = false self.hasAppliedLiveHistory = false self.pendingLocalUserEchoMessageIDsByRunID.removeAll() self.runMessageScopesByRunID.removeAll() self.provisionalFinalMessagesByID.removeAll() resetOutboxPresentationForSessionSwitch() self.sessionId = nil self.pendingToolCallsById = [:] self.updateStreamingAssistantText(nil) self.updateActiveSessionRunWithoutChatSnapshot(false) self.resetSlashCommandCatalog() self.clearPendingRuns(reason: nil) } private static func isUnsupportedCreateSessionError(_ error: Error) -> Bool { let nsError = error as NSError return nsError.domain == "OpenClawChatTransport" && nsError.localizedDescription == "sessions.create not supported by this transport" } func performReset() async { self.isLoading = true self.errorText = nil do { try await self.transport.resetSession(sessionKey: self.sessionKey) } catch { self.isLoading = false self.errorText = error.localizedDescription chatUILogger.error("session reset failed \(error.localizedDescription, privacy: .public)") return } self.runMessageScopesByRunID.removeAll() self.provisionalFinalMessagesByID.removeAll() self.startBootstrap() } func performCompact() async { guard !self.isCompacting else { return } guard !self.isSending, !self.hasBlockingRunActivity, !self.isAborting else { self.errorText = "Wait for the current response before compacting the session." return } if let lastCompactAt, Date().timeIntervalSince(lastCompactAt) < compactCooldown { self.errorText = "Please wait before compacting this session again." return } self.isCompacting = true self.isLoading = true self.errorText = nil defer { self.isCompacting = false } do { try await self.transport.compactSession(sessionKey: self.sessionKey) } catch { self.isLoading = false self.errorText = "Unable to compact the session. Please try again." let nsError = error as NSError chatUILogger.error( "compact failed domain=\(nsError.domain, privacy: .public) code=\(nsError.code, privacy: .public)") chatUILogger.error("compact details=\(String(describing: error), privacy: .private)") return } lastCompactAt = Date() self.startBootstrap() } private func reserveModelSelection(_ selectionID: String) -> ModelSelectionRequest? { let next = self.normalizedSelectionID(selectionID) guard next != self.modelSelectionID else { return nil } let session = self.currentSessionSnapshot() let sessionEntryKey = self.currentSessionEntry()?.key let target = self.modelPatchTarget( sessionKey: session.key, canonicalSessionKey: sessionEntryKey, agentID: session.deliveryAgentID, sessionRoutingContract: session.sessionRoutingContract) let previous = self.modelSelectionID let rollbackSelectionID = self.lastSuccessfulModelSelectionIDsByTarget[target] ?? previous let previousRequestID = self.latestModelSelectionRequestIDsByTarget[target] self.nextModelSelectionRequestID &+= 1 let requestID = self.nextModelSelectionRequestID let nextModelRef = self.modelRef(forSelectionID: next) self.latestModelSelectionRequestIDsByTarget[target] = requestID self.beginModelPatch(for: target) self.modelSelectionID = next self.syncThinkingLevelOptions() self.errorText = nil return ModelSelectionRequest( id: requestID, target: target, session: session, sessionEntryKey: sessionEntryKey, rollbackSelectionID: rollbackSelectionID, previousRequestID: previousRequestID, selectionID: next, modelRef: nextModelRef) } private func performSelectModel(_ request: ModelSelectionRequest) async { defer { self.endModelPatch(for: request.target) } do { let patchResult = try await self.transport.patchSessionModel( sessionKey: request.target.canonicalSessionKey, agentID: request.target.agentID, model: request.modelRef) guard request.id == self.latestModelSelectionRequestIDsByTarget[request.target] else { // Keep older successful patches as rollback state, but do not replay // stale UI/session state over a newer queued or completed selection. self.recordSuccessfulModelPatch( selectionID: request.selectionID, patchResult: patchResult, target: request.target) return } self.applySuccessfulModelSelection( request.selectionID, target: request.target, sessionEntryKey: patchResult?.key ?? request.sessionEntryKey, syncSelection: self.isCurrentSession(request.session), patchResult: patchResult) self.modelPickerStore.recordRecent(request.selectionID) self.modelPickerRecents = self.modelPickerStore.recents } catch { guard request.id == self.latestModelSelectionRequestIDsByTarget[request.target] else { return } let rollbackSelectionID = self.lastSuccessfulModelSelectionIDsByTarget[request.target] ?? request.rollbackSelectionID if let previousRequestID = request.previousRequestID { self.latestModelSelectionRequestIDsByTarget[request.target] = previousRequestID } else { self.latestModelSelectionRequestIDsByTarget.removeValue(forKey: request.target) } if self.lastSuccessfulModelSelectionIDsByTarget[request.target] == rollbackSelectionID { self.applySuccessfulModelSelection( rollbackSelectionID, target: request.target, sessionEntryKey: request.sessionEntryKey, syncSelection: self.isCurrentSession(request.session), patchResult: self.lastSuccessfulModelPatchResultsByTarget[request.target]) } guard self.isCurrentSession(request.session) else { return } self.modelSelectionID = rollbackSelectionID self.syncThinkingLevelOptions() self.errorText = error.localizedDescription chatUILogger.error("sessions.patch(model) failed \(error.localizedDescription, privacy: .public)") } } private func finishModelPatchTail(requestID: UInt64, target: ModelPatchTarget) { guard self.modelPatchTailsByTarget[target]?.requestID == requestID else { return } self.modelPatchTailsByTarget.removeValue(forKey: target) } private func beginModelPatch(for target: ModelPatchTarget) { self.modelPatchRevisionsByTarget[target, default: 0] &+= 1 self.inFlightModelPatchCountsByTarget[target, default: 0] += 1 } private func endModelPatch(for target: ModelPatchTarget) { self.modelPatchRevisionsByTarget[target, default: 0] &+= 1 let remaining = max(0, (inFlightModelPatchCountsByTarget[target] ?? 0) - 1) if remaining == 0 { self.inFlightModelPatchCountsByTarget.removeValue(forKey: target) let waiters = self.modelPatchWaitersByTarget.removeValue(forKey: target) ?? [] for waiter in waiters { waiter.resume() } return } self.inFlightModelPatchCountsByTarget[target] = remaining } /// Internal for the outbox flush, which must honor the same ordering /// behind in-flight model patches as the live send path. func waitForPendingModelPatches( in sessionKey: String, canonicalSessionKey: String? = nil, agentID: String? = nil, sessionRoutingContract: String? = nil) async { let target: ModelPatchTarget if canonicalSessionKey == nil, agentID == nil, sessionRoutingContract == nil, sessionKey == self.sessionKey { let session = self.currentSessionSnapshot() target = self.modelPatchTarget( sessionKey: session.key, canonicalSessionKey: self.currentSessionEntry()?.key, agentID: session.deliveryAgentID, sessionRoutingContract: session.sessionRoutingContract) } else { target = self.modelPatchTarget( sessionKey: sessionKey, canonicalSessionKey: canonicalSessionKey, agentID: agentID, sessionRoutingContract: sessionRoutingContract) } await self.waitForPendingModelPatches(for: target) } private func waitForPendingModelPatches(for target: ModelPatchTarget) async { guard (self.inFlightModelPatchCountsByTarget[target] ?? 0) > 0 else { return } await withCheckedContinuation { continuation in self.modelPatchWaitersByTarget[target, default: []].append(continuation) } } func placeholderSession(key: String) -> OpenClawChatSessionEntry { OpenClawChatSessionEntry( key: key, kind: nil, displayName: nil, surface: nil, subject: nil, room: nil, space: nil, updatedAt: nil, sessionId: nil, systemSent: nil, abortedLastRun: nil, thinkingLevel: nil, verboseLevel: nil, inputTokens: nil, outputTokens: nil, totalTokens: nil, modelProvider: nil, model: nil, contextTokens: nil) } func syncSelectedModel() { let currentSession = self.currentSessionEntry() let target = self.currentModelPatchTarget() let explicitModelID = self.normalizedModelSelectionID( currentSession?.model, provider: currentSession?.modelProvider) let defaultModelID = self.normalizedModelSelectionID( self.sessionDefaults?.model, provider: self.sessionDefaults?.modelProvider) if self.lastSuccessfulModelSelectionIDsByTarget[target] == Self.defaultModelSelectionID, explicitModelID == defaultModelID { self.modelSelectionID = Self.defaultModelSelectionID return } if let explicitModelID { self.lastSuccessfulModelSelectionIDsByTarget[target] = explicitModelID self.modelSelectionID = explicitModelID return } self.lastSuccessfulModelSelectionIDsByTarget[target] = Self.defaultModelSelectionID self.modelSelectionID = Self.defaultModelSelectionID } private func normalizedSelectionID(_ selectionID: String) -> String { let trimmed = selectionID.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return Self.defaultModelSelectionID } return trimmed } private func normalizedModelSelectionID(_ modelID: String?, provider: String? = nil) -> String? { guard let modelID else { return nil } let trimmed = modelID.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } if let provider = Self.normalizedProvider(provider) { let providerQualified = Self.providerQualifiedModelSelectionID(modelID: trimmed, provider: provider) if let match = modelChoices.first(where: { $0.selectionID == providerQualified || ($0.modelID == trimmed && Self.normalizedProvider($0.provider) == provider) }) { return match.selectionID } return providerQualified } if self.modelChoices.contains(where: { $0.selectionID == trimmed }) { return trimmed } let matches = self.modelChoices.filter { $0.modelID == trimmed || $0.selectionID == trimmed } if matches.count == 1 { return matches[0].selectionID } return trimmed } private func modelRef(forSelectionID selectionID: String) -> String? { let normalized = self.normalizedSelectionID(selectionID) if normalized == Self.defaultModelSelectionID { return nil } return normalized } private func generatedNewSessionKey() -> String { let baseKey = "ios-\(UUID().uuidString.lowercased())" guard let agentID = Self.agentID(fromSessionKey: sessionKey) ?? activeAgentId ?? Self.agentID(fromSessionKey: resolvedMainSessionKey) ?? sessions.lazy.compactMap({ Self.agentID(fromSessionKey: $0.key) }).first else { return baseKey } return "agent:\(agentID):\(baseKey)" } static func agentID(fromSessionKey sessionKey: String) -> String? { let parts = sessionKey .trimmingCharacters(in: .whitespacesAndNewlines) .split(separator: ":", omittingEmptySubsequences: false) guard parts.count >= 3, parts[0].lowercased() == "agent" else { return nil } let agentID = String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines) return agentID.isEmpty ? nil : agentID } private func modelLabel(for modelID: String) -> String { self.modelChoices.first(where: { $0.selectionID == modelID || $0.modelID == modelID })?.displayLabel ?? modelID } private func applySuccessfulModelSelection( _ selectionID: String, target: ModelPatchTarget, sessionEntryKey: String?, syncSelection: Bool, patchResult: OpenClawChatModelPatchResult? = nil) { self.recordSuccessfulModelPatch( selectionID: selectionID, patchResult: patchResult, target: target) if target.canonicalSessionKey.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "global", let targetAgentID = target.agentID, targetAgentID != self.activeAgentId { return } let resolved: (modelID: String?, modelProvider: String?) = if selectionID == Self.defaultModelSelectionID { (modelID: nil, modelProvider: nil) } else if let model = patchResult?.model { (modelID: model, modelProvider: patchResult?.modelProvider) } else { self.resolvedSessionModelIdentity(forSelectionID: selectionID) } let modelStateKey = sessionEntryKey ?? target.canonicalSessionKey self.updateCurrentSessionModel( modelID: resolved.modelID, modelProvider: resolved.modelProvider, sessionKey: modelStateKey, syncSelection: syncSelection) if let thinkingLevels = patchResult?.thinkingLevels { self.updateCurrentSessionThinkingLevels(thinkingLevels, sessionKey: modelStateKey) } if syncSelection, !self.prefersExplicitThinkingLevel, Self.normalizedThinkingLevel(self.preferredThinkingLevel) != "ultra", let thinkingLevel = Self.normalizedThinkingLevel(patchResult?.thinkingLevel) { self.preferredThinkingLevel = thinkingLevel } if let thinkingLevel = Self.normalizedThinkingLevel(patchResult?.thinkingLevel) { self.updateCurrentSessionThinkingLevel(thinkingLevel, sessionKey: modelStateKey) } if syncSelection { self.syncThinkingLevelOptions() } } private func recordSuccessfulModelPatch( selectionID: String, patchResult: OpenClawChatModelPatchResult?, target: ModelPatchTarget) { self.lastSuccessfulModelSelectionIDsByTarget[target] = selectionID self.lastSuccessfulModelPatchResultsByTarget[target] = patchResult self.completedModelPatchTargets.insert(target) } private func resolvedSessionModelIdentity(forSelectionID selectionID: String) -> (modelID: String?, modelProvider: String?) { guard let modelRef = modelRef(forSelectionID: selectionID) else { return (nil, nil) } if let choice = modelChoices.first(where: { $0.selectionID == modelRef }) { return (choice.modelID, Self.normalizedProvider(choice.provider)) } return (modelRef, nil) } }