Files
openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift
T
Peter SteinbergerandGitHub 81362bf643 feat(apps,ui): question surface parity and composer takeover (#110681)
* feat(apple): question parity — persistent summaries, skip, reconnect fallback, macOS keyboard

* feat(android): question parity — persistent summaries, skip, reconnect fallback

* feat(ui): question panel replaces composer while expanded

* fix(apps): regenerate question parity contracts

* fix(apps): preserve question reconciliation state

* fix(apps): keep question terminal state monotonic

* test(android): satisfy question test formatting

* fix(security): restore skill audit file reads

* fix(apps): harden question parity edge cases

* fix(apps): preserve expired question summaries

* fix(apps): keep question recovery retries current

* fix(apps): retry stale question recovery snapshots

* fix(android): reset stale question retry budgets

* docs(tools): clarify question composer takeover

* fix(apps): satisfy question recovery release gates

* fix(apps): preserve unknown question outcomes

* fix(apps): harden question action state

* fix(apps): keep unavailable questions terminal

* fix(apps): finalize local question outcomes

* fix(apps): preserve question card API

* fix(apps): reconcile external question answers

* fix(apps): reset question backoff budget

* chore(apps): refresh native i18n inventory

* fix(android): distinguish question skip progress

* fix(android): reconcile locally expired questions

* fix(apps): keep question recovery state fresh

* docs: note question surface parity

* fix(ui): reconcile IME draft before question takeover

* docs: leave question parity notes to release workflow
2026-07-18 21:42:15 +01:00

1584 lines
68 KiB
Swift

import Foundation
import Observation
import OpenClawKit
import OSLog
// Module-internal: ChatViewModel extension files share this logger.
let chatUILogger = Logger(subsystem: "ai.openclaw", category: "OpenClawChatUI")
@MainActor
@Observable
public final class OpenClawChatViewModel {
public nonisolated static let defaultModelSelectionID = "__default__"
public nonisolated static let inheritedThinkingSelectionID = "__inherited__"
static let maxAttachmentBytes = 5_000_000
static let sessionListFetchLimit = 200
public internal(set) var messages: [OpenClawChatMessage] = []
public var input: String = "" {
didSet {
guard self.input != oldValue else { return }
self.noteComposerInputChanged()
}
}
public internal(set) var replyTarget: OpenClawChatReplyTarget?
@ObservationIgnored
var inputHistoriesBySession: [String: ChatInputHistory] = [:]
/// Unlike web persistence, native drafts stay in memory. Attachments are excluded because
/// the staging guard prevents session switches while they are being prepared.
@ObservationIgnored
var draftsBySession: [String: String] = [:]
@ObservationIgnored
var composerRevisionsBySession: [String: UInt64] = [:]
@ObservationIgnored
var savedDraftRevisionsBySession: [String: UInt64] = [:]
@ObservationIgnored
var isApplyingRecalledInput = false
/// 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 internal(set) var preferredVerboseLevel: String
var prefersExplicitVerboseLevel: Bool
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?
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
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
/// Bumped after every successful group-catalog mutation so views keyed on it
/// refetch; catalog-only changes (e.g. creating an empty group) alter no
/// session rows and would otherwise stay stale until reconnect.
public internal(set) var sessionGroupsRevision = 0
/// 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
public internal(set) var questionCards: [OpenClawQuestionCardModel] = []
var questionRefreshGeneration: UInt64 = 0
var questionStateRevision: UInt64 = 0
var questionExpiryTasks: [String: Task<Void, Never>] = [:]
var questionExpiryDeadlines: [String: Date] = [:]
var questionRefreshRetryTask: Task<Void, Never>?
var questionRefreshRetryDelaysMs: [Int64] = [1000, 2000, 4000]
var hasActiveSessionRunWithoutChatSnapshot = false
public private(set) var sessionKey: String {
didSet { syncContextUsageFraction() }
}
public internal(set) var sessionId: String?
public private(set) var streamingAssistantText: String?
public private(set) var pendingToolCalls: [OpenClawChatPendingToolCall] = []
public internal(set) var planSteps: [OpenClawChatPlanStep] = []
public internal(set) var planExplanation: String?
var planRunId: String?
private(set) var timelineRevision: UInt64 = 0
/// Setter is module-internal for the transcript-cache extension only.
public internal(set) var sessions: [OpenClawChatSessionEntry] = [] {
didSet { 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
@ObservationIgnored
var unreadPatchGuard = ChatSessionUnreadPatchGuard()
let unreadMutationQueue = ChatSessionUnreadMutationQueue()
/// 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<String> = []
@ObservationIgnored
var outboxPresentationGeneration: UInt64 = 0
@ObservationIgnored
var outboxChangesTask: Task<Void, Never>?
/// 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<Void, Never>?
/// A command becomes terminally 'failed' after this many send attempts.
nonisolated static let maxOutboxSendAttempts = 3
@ObservationIgnored
var pendingCacheWriteTask: Task<Void, Never>?
private(set) var activeAgentId: String?
private(set) var sessionRoutingContract: String?
var sessionDefaults: OpenClawChatSessionsDefaults? {
didSet { syncContextUsageFraction() }
}
var prefersExplicitThinkingLevel: Bool
private let onSessionChanged: (@MainActor (String) -> Void)?
let onThinkingLevelChanged: (@MainActor @Sendable (String) -> Void)?
let onThinkingPreferenceChanged: (@MainActor @Sendable (String?) -> Void)?
let onVerboseLevelChanged: (@MainActor @Sendable (String) -> Void)?
let onVerbosePreferenceChanged: (@MainActor @Sendable (String?) -> Void)?
private let diagnosticsLog: (@MainActor @Sendable (String) -> Void)?
let onToolActivity: OpenClawChatToolActivityHandler?
let attachmentOwnerIsActive: @MainActor () -> Bool
@ObservationIgnored
private nonisolated(unsafe) var eventTask: Task<Void, Never>?
@ObservationIgnored
private nonisolated(unsafe) var bootstrapTask: Task<Void, Never>?
var runOwnershipGeneration: UInt64 = 0
var latestAppliedRunSnapshotRequestID: UInt64 = 0
var isApplyingRunSnapshot = false
var pendingRuns = Set<String>() {
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] = [:]
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.
var lastIssuedHistoryRequestID: UInt64 = 0
var latestAppliedHistoryRequestID: UInt64 = 0
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<Void, Never>] = [:]
var nextPendingRunOwnerArmID: UInt64 = 0
var pendingRunOwnerArmIDs: [String: UInt64] = [:]
@ObservationIgnored
nonisolated(unsafe) var activeSessionRunIndicatorTimeoutTask: Task<Void, Never>?
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 nextSessionSettingsRequestID: UInt64 = 0
private var latestModelSelectionRequestIDsByTarget: [ModelPatchTarget: UInt64] = [:]
private var lastSuccessfulModelSelectionIDsByTarget: [ModelPatchTarget: String] = [:]
var lastSuccessfulSettingsPatchRequestIDsByTarget: [ModelPatchTarget: UInt64] = [:]
/// Rollback and pre-refresh sends need the authoritative state from the latest settings patch.
var lastSuccessfulSettingsPatchResultsByTarget: [ModelPatchTarget: OpenClawChatModelPatchResult] = [:]
var completedModelPatchTargets: Set<ModelPatchTarget> = []
var inFlightSettingsPatchCountsByTarget: [ModelPatchTarget: Int] = [:]
private var settingsPatchRevisionsByTarget: [ModelPatchTarget: UInt64] = [:]
private var settingsPatchWaitersByTarget: [ModelPatchTarget: [CheckedContinuation<Void, Never>]] = [:]
@ObservationIgnored
private var settingsPatchTailsByTarget: [ModelPatchTarget: SettingsPatchTail] = [:]
var nextThinkingSelectionRequestID: UInt64 = 0
var latestThinkingSelectionRequestIDsByTarget: [ModelPatchTarget: UInt64] = [:]
var confirmedThinkingPreference: ThinkingPreferenceState
var emittedThinkingPreference: ThinkingPreferenceState
var thinkingPreferenceRequests: [UInt64: ThinkingPreferenceRequest] = [:]
var nextVerboseSelectionRequestID: UInt64 = 0
var confirmedVerbosePreference: VerbosePreferenceState
var emittedVerbosePreference: VerbosePreferenceState
var verbosePreferenceRequests: [UInt64: VerbosePreferenceRequest] = [:]
var acceptedVerboseLevelsByTarget: [ModelPatchTarget: VerboseLevelState] = [:]
var acceptedFastModesByTarget: [ModelPatchTarget: FastModeState] = [:]
var lastSuccessfulThinkingOverrideClearedByTarget: [ModelPatchTarget: Bool] = [:]
var lastSuccessfulFastOverrideClearedByTarget: [ModelPatchTarget: Bool] = [:]
var lastSuccessfulVerboseOverrideClearedByTarget: [ModelPatchTarget: Bool] = [:]
var acceptedSettingsPatchResultsByTarget: [ModelPatchTarget: OpenClawChatModelPatchResult] = [:]
var acceptedThinkingLevelsByTarget: [ModelPatchTarget: String] = [:]
var acceptedPreferredThinkingLevelsByTarget: [ModelPatchTarget: String] = [:]
var acceptedExplicitThinkingPreferencesByTarget: [ModelPatchTarget: Bool] = [:]
var acceptedThinkingOverrideClearedByTarget: [ModelPatchTarget: Bool] = [:]
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?
}
struct VerbosePreferenceState: Equatable {
let level: String
let isExplicit: Bool
}
enum VerbosePreferenceRequest {
case pending(VerbosePreferenceState)
case succeeded(VerbosePreferenceState)
case failed
}
struct ThinkingPreferenceState: Equatable {
let level: String
let isExplicit: Bool
}
enum ThinkingPreferenceRequest {
case pending(ThinkingPreferenceState)
case succeeded(ThinkingPreferenceState)
case failed
}
enum VerboseLevelState {
case none
case value(String)
var level: String? {
if case let .value(level) = self { return level }
return nil
}
}
struct FastModeState {
let override: OpenClawChatFastMode?
let effective: OpenClawChatFastMode?
}
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 SettingsPatchTail {
let requestID: UInt64
let routeLeaseTask: Task<OpenClawChatSessionSettingsRouteLease?, Never>
let task: Task<Void, Never>
}
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<String>
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 }
reportToolActivityChanges(from: oldValue, to: self.pendingToolCallsById)
self.pendingToolCalls = self.pendingToolCallsById.values
.sorted { ($0.startedAt ?? 0) < ($1.startedAt ?? 0) }
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,
initialVerboseLevel: String? = nil,
onSessionChanged: (@MainActor (String) -> Void)? = nil,
onThinkingLevelChanged: (@MainActor @Sendable (String) -> Void)? = nil,
onToolActivity: OpenClawChatToolActivityHandler? = nil,
onThinkingPreferenceChanged: (@MainActor @Sendable (String?) -> Void)? = nil,
onVerboseLevelChanged: (@MainActor @Sendable (String) -> Void)? = nil,
onVerbosePreferenceChanged: (@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
let initialThinkingPreference = ThinkingPreferenceState(
level: initialResolvedThinkingLevel,
isExplicit: normalizedThinkingLevel != nil)
self.confirmedThinkingPreference = initialThinkingPreference
self.emittedThinkingPreference = initialThinkingPreference
let normalizedVerboseLevel = Self.normalizedVerboseLevel(initialVerboseLevel)
let initialResolvedVerboseLevel = normalizedVerboseLevel ?? "off"
self.preferredVerboseLevel = initialResolvedVerboseLevel
self.prefersExplicitVerboseLevel = normalizedVerboseLevel != nil
let initialVerbosePreference = VerbosePreferenceState(
level: initialResolvedVerboseLevel,
isExplicit: normalizedVerboseLevel != nil)
self.confirmedVerbosePreference = initialVerbosePreference
self.emittedVerbosePreference = initialVerbosePreference
self.onSessionChanged = onSessionChanged
self.onThinkingLevelChanged = onThinkingLevelChanged
self.onToolActivity = onToolActivity
self.onThinkingPreferenceChanged = onThinkingPreferenceChanged
self.onVerboseLevelChanged = onVerboseLevelChanged
self.onVerbosePreferenceChanged = onVerbosePreferenceChanged
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)
}
}
}
}
isolated deinit {
self.reportToolActivityChanges(from: self.pendingToolCallsById, to: [:])
self.eventTask?.cancel()
self.bootstrapTask?.cancel()
self.outboxRetryTask?.cancel()
self.outboxChangesTask?.cancel()
self.activeSessionRunIndicatorTimeoutTask?.cancel()
self.questionRefreshRetryTask?.cancel()
for (_, task) in self.questionExpiryTasks {
task.cancel()
}
for (_, task) in self.pendingRunOwnerTasks {
task.cancel()
}
}
public func load() {
startBootstrap()
}
public func refresh() {
startBootstrap()
}
public var modelPickerSections: ChatModelPickerSections {
let defaultProvider = ChatModelPickerStore.resolvedDefaultProvider(
provider: self.sessionDefaults?.modelProvider,
model: self.sessionDefaults?.model)
return ChatModelPickerStore.sections(
choices: self.modelChoices,
favorites: self.modelPickerFavorites,
recents: self.modelPickerRecents,
defaultProvider: defaultProvider)
}
public func isDefaultModel(_ model: OpenClawChatModelChoice) -> Bool {
ChatModelPickerStore.isDefaultModel(
model,
defaultProvider: self.sessionDefaults?.modelProvider,
defaultModel: self.sessionDefaults?.model)
}
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) {
applySessionSwitch(to: sessionKey, intent: .userInitiated)
}
public func syncSession(to sessionKey: String) {
applySessionSwitch(to: sessionKey, intent: .externalSync)
}
// periphery:ignore - package tests vary one identity field while preserving the current routing contract.
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 blocksAttachmentOwnerChange {
self.deferredDeliveryIdentity = nil
}
return
}
if 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.
advanceSessionGeneration()
clearSessionOwnedState()
startBootstrap()
}
public func selectThinkingLevel(_ level: String) {
performSelectThinkingLevel(level)
}
public func selectVerboseLevel(_ level: String) {
performSelectVerboseLevel(level)
}
public func selectFastMode(_ selectionID: String) {
performSelectFastMode(selectionID)
}
public func selectModel(_ selectionID: String) {
guard let request = reserveModelSelection(selectionID) else { return }
enqueueSessionSettingsPatch(requestID: request.id, target: request.target) { [weak self] routeLease in
guard let self else { return }
await self.performSelectModel(request, routeLease: routeLease)
}
}
var resolvedMainSessionKey: String {
let trimmed = self.sessionDefaults?.mainSessionKey?
.trimmingCharacters(in: .whitespacesAndNewlines)
return (trimmed?.isEmpty == false ? trimmed : nil) ?? "main"
}
private var usesMutableAgentRouting: Bool {
OpenClawChatSessionKey.agentID(from: 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 OpenClawChatSessionKey.agentID(from: 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: \(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"),
]
}
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 logDiagnostic(_ message: String) {
self.diagnosticsLog?(message)
}
func currentSessionSnapshot() -> SessionSnapshot {
SessionSnapshot(
key: self.sessionKey,
generation: self.sessionGeneration,
agentID: self.activeAgentId,
deliveryAgentID: OpenClawChatSessionKey.agentID(from: 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 startBootstrap(sessionKey requestedSessionKey: String? = nil) {
let sessionKey = requestedSessionKey ?? self.sessionKey
guard sessionKey == self.sessionKey else { return }
self.unreadPatchGuard.activate(key: self.sessionMutationIdentity(for: sessionKey))
self.bootstrapGeneration &+= 1
self.bootstrapTask?.cancel()
self.isLoading = true
self.errorText = nil
self.invalidateSessionMetadataReadiness()
self.healthOK = false
clearPendingRuns(reason: nil)
self.pendingToolCallsById = [:]
self.updateStreamingAssistantText(nil)
clearPlan()
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 }
Task { [weak self] in await self?.refreshQuestions() }
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 }
// A sidebar-selected row can sit outside the bootstrap's 50-row refresh.
// Retain its unread metadata until activation acknowledgement finishes.
let activationEntry = self.currentSessionEntry()
await self.fetchSessions(limit: 50, sessionSnapshot: context.session)
guard self.isCurrentBootstrap(context) else { return }
await self.markCurrentSessionReadAfterActivation(
context.session,
fallbackEntry: activationEntry)
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)
clearPlan()
// 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)
clearPendingRuns(
reason: nil,
hapticEvent: assistantHapticEventAfterLatestUser())
self.pendingToolCallsById = [:]
self.updateStreamingAssistantText(nil)
clearPlan()
}
}
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 = modelPatchTarget(
sessionKey: session.key,
canonicalSessionKey: self.isCurrentSession(session) ? currentSessionEntry()?.key : nil,
agentID: session.deliveryAgentID,
sessionRoutingContract: session.sessionRoutingContract)
var overlappingSuccessfulSettingsPatchRequestID: UInt64?
// Request IDs start at one, so zero represents no earlier success.
var pendingSettingsPatchOverlapBaseline: UInt64?
while true {
await self.waitForPendingSessionSettings(for: target)
if let pendingBaseline = pendingSettingsPatchOverlapBaseline {
let completedRequestID = self.lastSuccessfulSettingsPatchRequestIDsByTarget[target] ?? 0
if completedRequestID != pendingBaseline {
overlappingSuccessfulSettingsPatchRequestID = completedRequestID
}
pendingSettingsPatchOverlapBaseline = nil
}
if let sessionSnapshot, !self.isCurrentSession(sessionSnapshot) {
return
}
let metadataGeneration = self.sessionMetadataGeneration
let settingsPatchRevision = self.settingsPatchRevisionsByTarget[target, default: 0]
let successfulSettingsPatchRequestID = self.lastSuccessfulSettingsPatchRequestIDsByTarget[target]
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 {
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 {
overlappingSuccessfulSettingsPatchRequestID = nil
pendingSettingsPatchOverlapBaseline = nil
continue
}
guard settingsPatchRevision == self.settingsPatchRevisionsByTarget[target, default: 0],
self.inFlightSettingsPatchCountsByTarget[target] == nil
else {
let completedRequestID = self.lastSuccessfulSettingsPatchRequestIDsByTarget[target]
if let completedRequestID,
completedRequestID != successfulSettingsPatchRequestID
{
overlappingSuccessfulSettingsPatchRequestID = completedRequestID
}
if self.inFlightSettingsPatchCountsByTarget[target] != nil {
pendingSettingsPatchOverlapBaseline = completedRequestID ?? 0
}
continue
}
self.latestAppliedSessionsFetchRequestID = sessionsFetchRequestID
let organized = OpenClawChatSessionListOrganizer.organize(res.sessions)
for session in organized {
self.unreadPatchGuard.observe(
key: self.sessionMutationIdentity(for: session.key, listedKey: session.key),
unread: session.unread)
}
self.sessions = self.applyingLocalUnreadOverrides(to: organized)
self.sessionDefaults = res.defaults
self.restoreOverlappingSettingsPatch(
requestID: overlappingSuccessfulSettingsPatchRequestID,
target: target)
self.hasAppliedLiveSessions = true
self.syncSelectedModel()
syncThinkingLevelOptions()
persistSessionsToCache(organized)
self.readySessionMetadataGeneration = metadataGeneration
if self.healthOK {
flushOutboxIfNeeded()
}
return
}
}
private func restoreOverlappingSettingsPatch(requestID: UInt64?, target: ModelPatchTarget) {
guard let requestID,
self.lastSuccessfulSettingsPatchRequestIDsByTarget[target] == requestID
else { return }
// A post-patch list retry may still carry the pre-patch row. Preserve
// only the route whose patch overlapped this fetch.
let patchResult = self.lastSuccessfulSettingsPatchResultsByTarget[target]
let resultKey = patchResult?.key ?? target.canonicalSessionKey
if let selectionID = self.lastSuccessfulModelSelectionIDsByTarget[target] {
self.applySuccessfulModelSelection(
selectionID,
target: target,
sessionEntryKey: resultKey,
syncSelection: false,
patchResult: patchResult)
} else if let thinkingLevel = patchResult?.thinkingLevel,
self.lastSuccessfulThinkingOverrideClearedByTarget[target] != true
{
self.updateCurrentSessionThinkingLevel(thinkingLevel, sessionKey: resultKey)
}
if self.lastSuccessfulThinkingOverrideClearedByTarget[target] == true {
self.updateCurrentSessionThinkingLevel(nil, sessionKey: resultKey)
}
if let patchResult {
self.applyModelControlPatchResult(
patchResult,
sessionKey: resultKey,
fastOverrideCleared: self.lastSuccessfulFastOverrideClearedByTarget[target] == true,
verboseOverrideCleared: self.lastSuccessfulVerboseOverrideClearedByTarget[target] == true)
}
}
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 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.prepareComposerForSessionSwitch(to: next)
self.advanceSessionGeneration()
self.clearSessionOwnedState()
self.sessionKey = next
self.restoreComposerAfterSessionSwitch()
if intent == .userInitiated {
self.onSessionChanged?(next)
}
self.startBootstrap(sessionKey: next)
}
func applyDeferredExternalStateIfReady() {
guard !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)
}
/// Adopts a freshly created session key: full composer-preserving switch plus
/// session-owned state reset. Module-internal so the session-actions extension
/// does not need access to the private switch members.
func adoptCreatedSession(_ next: String) {
self.prepareComposerForSessionSwitch(to: next)
self.advanceSessionGeneration()
self.clearSessionOwnedState()
self.sessionKey = next
self.restoreComposerAfterSessionSwitch()
self.onSessionChanged?(next)
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)
clearPlan()
self.updateActiveSessionRunWithoutChatSnapshot(false)
resetSlashCommandCatalog()
clearPendingRuns(reason: nil)
}
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.replyTarget = nil
self.runMessageScopesByRunID.removeAll()
self.provisionalFinalMessagesByID.removeAll()
self.startBootstrap()
}
func performCompact() async {
guard !self.isCompacting else { return }
guard !self.isSending, !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 = currentSessionEntry()?.key
let target = 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]
let requestID = self.reserveSessionSettingsRequest(for: target)
let nextModelRef = self.modelRef(forSelectionID: next)
self.latestModelSelectionRequestIDsByTarget[target] = requestID
self.modelSelectionID = next
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,
routeLease: OpenClawChatSessionSettingsRouteLease?) async
{
do {
guard let routeLease else { throw OpenClawChatTransportSendError.notDispatched }
let patchResult = try await routeLease.patchSessionSettings(
sessionKey: request.target.canonicalSessionKey,
agentID: request.target.agentID,
patch: OpenClawChatSessionSettingsPatch(model: .some(request.modelRef)))
self.lastSuccessfulSettingsPatchRequestIDsByTarget[request.target] = request.id
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.lastSuccessfulSettingsPatchResultsByTarget[request.target])
}
guard self.isCurrentSession(request.session) else { return }
self.modelSelectionID = rollbackSelectionID
syncThinkingLevelOptions()
self.errorText = error.localizedDescription
chatUILogger.error("sessions.patch(model) failed \(error.localizedDescription, privacy: .public)")
}
}
private func finishSettingsPatchTail(requestID: UInt64, target: ModelPatchTarget) {
guard self.settingsPatchTailsByTarget[target]?.requestID == requestID else { return }
self.settingsPatchTailsByTarget.removeValue(forKey: target)
}
func reserveSessionSettingsRequest(for target: ModelPatchTarget) -> UInt64 {
self.nextSessionSettingsRequestID &+= 1
self.beginSettingsPatch(for: target)
return self.nextSessionSettingsRequestID
}
func enqueueSessionSettingsPatch(
requestID: UInt64,
target: ModelPatchTarget,
operation: @escaping @MainActor (OpenClawChatSessionSettingsRouteLease?) async -> Void)
{
let previousPatchTail = self.settingsPatchTailsByTarget[target]
let previousTail = previousPatchTail?.task
let previousRouteLeaseTask = previousPatchTail?.routeLeaseTask
// Task scheduling is not FIFO. Chain lease capture separately so a
// reconnect cannot give an older mutation a newer route than its successor.
let routeLeaseTask = Task { [weak self] in
_ = await previousRouteLeaseTask?.value
return await self?.transport.acquireSessionSettingsRouteLease()
}
let task = Task { [weak self] in
let routeLease = await routeLeaseTask.value
await previousTail?.value
guard let self else { return }
await operation(routeLease)
self.endSettingsPatch(for: target)
self.finishSettingsPatchTail(requestID: requestID, target: target)
}
self.settingsPatchTailsByTarget[target] = SettingsPatchTail(
requestID: requestID,
routeLeaseTask: routeLeaseTask,
task: task)
}
private func beginSettingsPatch(for target: ModelPatchTarget) {
self.settingsPatchRevisionsByTarget[target, default: 0] &+= 1
self.inFlightSettingsPatchCountsByTarget[target, default: 0] += 1
}
private func endSettingsPatch(for target: ModelPatchTarget) {
self.settingsPatchRevisionsByTarget[target, default: 0] &+= 1
let remaining = max(0, (inFlightSettingsPatchCountsByTarget[target] ?? 0) - 1)
if remaining == 0 {
self.inFlightSettingsPatchCountsByTarget.removeValue(forKey: target)
// Rollback baselines belong to one contiguous settings lane. Once
// drained, the next authoritative session snapshot owns state.
self.acceptedSettingsPatchResultsByTarget.removeValue(forKey: target)
self.acceptedThinkingLevelsByTarget.removeValue(forKey: target)
self.acceptedPreferredThinkingLevelsByTarget.removeValue(forKey: target)
self.acceptedExplicitThinkingPreferencesByTarget.removeValue(forKey: target)
self.acceptedThinkingOverrideClearedByTarget.removeValue(forKey: target)
self.acceptedVerboseLevelsByTarget.removeValue(forKey: target)
self.acceptedFastModesByTarget.removeValue(forKey: target)
self.latestThinkingSelectionRequestIDsByTarget.removeValue(forKey: target)
let waiters = self.settingsPatchWaitersByTarget.removeValue(forKey: target) ?? []
for waiter in waiters {
waiter.resume()
}
return
}
self.inFlightSettingsPatchCountsByTarget[target] = remaining
}
/// Internal for the outbox flush, which must honor the same ordering
/// behind in-flight settings patches as the live send path.
func waitForPendingSessionSettings(
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 = modelPatchTarget(
sessionKey: session.key,
canonicalSessionKey: currentSessionEntry()?.key,
agentID: session.deliveryAgentID,
sessionRoutingContract: session.sessionRoutingContract)
} else {
target = modelPatchTarget(
sessionKey: sessionKey,
canonicalSessionKey: canonicalSessionKey,
agentID: agentID,
sessionRoutingContract: sessionRoutingContract)
}
await self.waitForPendingSessionSettings(for: target)
}
private func waitForPendingSessionSettings(for target: ModelPatchTarget) async {
guard (self.inFlightSettingsPatchCountsByTarget[target] ?? 0) > 0 else { return }
await withCheckedContinuation { continuation in
self.settingsPatchWaitersByTarget[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 = currentSessionEntry()
let target = 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
}
/// Module-internal: the session-actions extension derives new-session keys.
func generatedNewSessionKey(agentID explicitAgentID: String? = nil) -> String {
let baseKey = "ios-\(UUID().uuidString.lowercased())"
guard let agentID = explicitAgentID ??
OpenClawChatSessionKey.agentID(from: sessionKey) ??
activeAgentId ??
OpenClawChatSessionKey.agentID(from: resolvedMainSessionKey) ??
sessions.lazy.compactMap({ OpenClawChatSessionKey.agentID(from: $0.key) }).first
else {
return baseKey
}
return "agent:\(agentID):\(baseKey)"
}
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 != 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
updateCurrentSessionModel(
modelID: resolved.modelID,
modelProvider: resolved.modelProvider,
sessionKey: modelStateKey,
syncSelection: syncSelection)
if let thinkingLevels = patchResult?.thinkingLevels {
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) {
updateCurrentSessionThinkingLevel(thinkingLevel, sessionKey: modelStateKey)
}
if syncSelection {
syncThinkingLevelOptions()
}
}
private func recordSuccessfulModelPatch(
selectionID: String,
patchResult: OpenClawChatModelPatchResult?,
target: ModelPatchTarget)
{
self.lastSuccessfulModelSelectionIDsByTarget[target] = selectionID
self.lastSuccessfulSettingsPatchResultsByTarget[target] = patchResult
if let thinkingLevel = Self.normalizedThinkingLevel(patchResult?.thinkingLevel) {
self.acceptedThinkingLevelsByTarget[target] = thinkingLevel
if self.acceptedExplicitThinkingPreferencesByTarget[target] == false {
self.acceptedPreferredThinkingLevelsByTarget[target] = thinkingLevel
self.recordAuthoritativeInheritedThinkingPreference(thinkingLevel)
}
}
if let patchResult {
let previous = self.acceptedSettingsPatchResultsByTarget[target]
self.acceptedSettingsPatchResultsByTarget[target] = OpenClawChatModelPatchResult(
key: patchResult.key ?? previous?.key,
modelProvider: patchResult.modelProvider ?? previous?.modelProvider,
model: patchResult.model ?? previous?.model,
thinkingLevel: patchResult.thinkingLevel ?? previous?.thinkingLevel,
thinkingLevels: patchResult.thinkingLevels ?? previous?.thinkingLevels,
fastMode: patchResult.fastMode ?? previous?.fastMode,
effectiveFastMode: patchResult.effectiveFastMode ?? previous?.effectiveFastMode,
verboseLevel: patchResult.verboseLevel ?? previous?.verboseLevel)
}
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)
}
}