Files
openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift
Peter Steinberger 73a0bf4b7c refactor(apple): share chat gateway payload mapping (#106154)
* refactor(apple): share chat gateway payload mapping

* test(apple): track shared event mapping
2026-07-13 02:39:01 -07:00

1640 lines
68 KiB
Swift

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<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 { 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<Void, Never>?
@ObservationIgnored
private nonisolated(unsafe) var bootstrapTask: Task<Void, Never>?
var runOwnershipGeneration: UInt64 = 0
var latestAppliedRunSnapshotRequestID: UInt64 = 0
private 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] = [:]
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<Void, Never>] = [:]
var nextPendingRunOwnerArmID: UInt64 = 0
var pendingRunOwnerArmIDs: [String: UInt64] = [:]
@ObservationIgnored
private 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 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<ModelPatchTarget> = []
private var inFlightModelPatchCountsByTarget: [ModelPatchTarget: Int] = [:]
private var modelPatchRevisionsByTarget: [ModelPatchTarget: UInt64] = [:]
private var modelPatchWaitersByTarget: [ModelPatchTarget: [CheckedContinuation<Void, Never>]] = [:]
@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<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 }
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)
}
// 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 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 {
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: \(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: 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 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 = 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 != 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)
}
}