Fix chat history races across agent switches

This commit is contained in:
joshavant
2026-06-05 23:19:33 -05:00
parent 7478e6e485
commit ea7e214bd4
2 changed files with 1512 additions and 244 deletions

View File

@@ -40,11 +40,19 @@ public final class OpenClawChatViewModel {
@ObservationIgnored
private nonisolated(unsafe) var eventTask: Task<Void, Never>?
@ObservationIgnored
private nonisolated(unsafe) var bootstrapTask: Task<Void, Never>?
private var pendingRuns = Set<String>() {
didSet { self.pendingRunCount = self.pendingRuns.count }
}
private var pendingLocalUserEchoMessageIDsByRunID: [String: UUID] = [:]
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
@ObservationIgnored
private nonisolated(unsafe) var pendingRunTimeoutTasks: [String: Task<Void, Never>] = [:]
@@ -77,6 +85,32 @@ public final class OpenClawChatViewModel {
case externalSync
}
private struct SessionSnapshot {
var key: String
var generation: UInt64
}
private struct BootstrapContext {
var id: UInt64
var historyRequest: HistoryRequest
var session: SessionSnapshot {
self.historyRequest.session
}
}
private struct HistoryRequest {
var id: UInt64
var session: SessionSnapshot
var latestUserTurn: LatestUserTurn?
}
private struct LatestUserTurn {
var refreshKey: String?
var occurrence: Int
var timestamp: Double?
}
private var pendingToolCallsById: [String: OpenClawChatPendingToolCall] = [:] {
didSet {
self.pendingToolCalls = self.pendingToolCallsById.values
@@ -121,17 +155,18 @@ public final class OpenClawChatViewModel {
deinit {
self.eventTask?.cancel()
self.bootstrapTask?.cancel()
for (_, task) in self.pendingRunTimeoutTasks {
task.cancel()
}
}
public func load() {
Task { await self.bootstrap() }
self.startBootstrap()
}
public func refresh() {
Task { await self.bootstrap() }
self.startBootstrap()
}
public func resumeFromForeground() {
@@ -152,7 +187,8 @@ public final class OpenClawChatViewModel {
}
public func refreshSessions(limit: Int? = nil) {
Task { await self.fetchSessions(limit: limit) }
let context = self.currentSessionSnapshot()
Task { await self.fetchSessions(limit: limit, sessionSnapshot: context) }
}
public func switchSession(to sessionKey: String) {
@@ -225,7 +261,7 @@ public final class OpenClawChatViewModel {
}
public var defaultModelLabel: String {
guard let defaultModelID = self.normalizedModelSelectionID(self.sessionDefaults?.model) else {
guard let defaultModelID = normalizedModelSelectionID(sessionDefaults?.model) else {
return "Default"
}
return "Default: \(self.modelLabel(for: defaultModelID))"
@@ -270,9 +306,91 @@ public final class OpenClawChatViewModel {
self.diagnosticsLog?(message)
}
private func bootstrap(sessionKey requestedSessionKey: String? = nil) async {
private func currentSessionSnapshot() -> SessionSnapshot {
SessionSnapshot(key: self.sessionKey, generation: self.sessionGeneration)
}
private func isCurrentSession(_ snapshot: SessionSnapshot) -> Bool {
self.sessionKey == snapshot.key && self.sessionGeneration == snapshot.generation
}
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
}
private func beginHistoryRequest(
for sessionSnapshot: SessionSnapshot? = nil,
captureLatestUserTurn: Bool = true) -> HistoryRequest
{
self.lastIssuedHistoryRequestID &+= 1
return HistoryRequest(
id: self.lastIssuedHistoryRequestID,
session: sessionSnapshot ?? self.currentSessionSnapshot(),
latestUserTurn: captureLatestUserTurn ? Self.latestUserTurn(in: self.messages) : nil)
}
private func markHistoryRequestApplied(_ request: HistoryRequest) {
self.latestAppliedHistoryRequestID = max(self.latestAppliedHistoryRequestID, request.id)
}
@discardableResult
private func applyHistoryPayload(
_ payload: OpenClawChatHistoryPayload,
for request: HistoryRequest,
preservingOptimisticLocalMessages: Bool,
syncThinkingOptions: Bool = false) -> Bool
{
guard self.canApplyHistory(request) else { return false }
let incoming = Self.decodeMessages(payload.messages ?? [])
self.messages = if preservingOptimisticLocalMessages {
Self.reconcileRunRefreshMessages(
previous: self.messages,
incoming: incoming,
pendingLocalUserEchoIDs: Set(self.pendingLocalUserEchoMessageIDsByRunID.values))
} else {
Self.reconcileMessageIDs(previous: self.messages, incoming: incoming)
}
self.prunePendingLocalUserEchoMessageIDs()
self.sessionId = payload.sessionId
// 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)
}
let appliedThinkingLevel = !self.prefersExplicitThinkingLevel
? Self.normalizedThinkingLevel(payload.thinkingLevel)
: nil
if let level = appliedThinkingLevel {
self.thinkingLevel = level
}
if syncThinkingOptions || appliedThinkingLevel != nil {
self.syncThinkingLevelOptions()
}
return true
}
private func startBootstrap(sessionKey requestedSessionKey: String? = nil) {
let sessionKey = requestedSessionKey ?? self.sessionKey
guard sessionKey == self.sessionKey else { return }
self.bootstrapGeneration &+= 1
let historyRequest = self.beginHistoryRequest(captureLatestUserTurn: requestedSessionKey == nil)
let context = BootstrapContext(
id: bootstrapGeneration,
historyRequest: historyRequest)
self.bootstrapTask?.cancel()
self.isLoading = true
self.errorText = nil
self.healthOK = false
@@ -280,74 +398,75 @@ public final class OpenClawChatViewModel {
self.pendingToolCallsById = [:]
self.streamingAssistantText = nil
self.sessionId = nil
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.sessionKey == sessionKey {
if self.isCurrentBootstrap(context) {
self.isLoading = false
}
}
do {
do {
try await self.transport.setActiveSessionKey(sessionKey)
} catch {
// Best-effort only; history/send/health still work without push events.
}
guard self.sessionKey == sessionKey else {
await self.restoreActiveSessionAfterStaleBootstrap(staleSessionKey: sessionKey)
return
}
await self.syncActiveSessionSubscription(startingWith: context.session.key)
guard self.isCurrentBootstrap(context) else { return }
let payload = try await self.transport.requestHistory(sessionKey: sessionKey)
guard self.sessionKey == sessionKey else { return }
self.messages = Self.reconcileMessageIDs(
previous: self.messages,
incoming: Self.decodeMessages(payload.messages ?? []))
self.prunePendingLocalUserEchoMessageIDs()
self.sessionId = payload.sessionId
if !self.prefersExplicitThinkingLevel,
let level = Self.normalizedThinkingLevel(payload.thinkingLevel)
{
self.thinkingLevel = level
}
self.syncThinkingLevelOptions()
await self.pollHealthIfNeeded(force: true)
guard self.sessionKey == sessionKey else { return }
await self.fetchSessions(limit: 50)
guard self.sessionKey == sessionKey else { return }
await self.fetchModels()
guard self.sessionKey == sessionKey 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 self.pollHealthIfNeeded(force: true, sessionSnapshot: context.session)
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.sessionKey == sessionKey else { return }
guard self.isCurrentBootstrap(context) else { return }
self.errorText = error.localizedDescription
chatUILogger.error("bootstrap failed \(error.localizedDescription, privacy: .public)")
}
}
private func restoreActiveSessionAfterStaleBootstrap(staleSessionKey: String) async {
var lastSubscribedSessionKey = staleSessionKey
private func syncActiveSessionSubscription(startingWith sessionKey: String) async {
var nextSessionKey = sessionKey
while true {
let currentSessionKey = self.sessionKey
guard currentSessionKey != lastSubscribedSessionKey else { return }
do {
// A stale bootstrap may complete its subscribe side effect after the winning switch.
// Reassert and recheck so push events stay aligned with the visible session.
try await self.transport.setActiveSessionKey(currentSessionKey)
// 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 {
// Best-effort only; the current bootstrap still owns history/send/health.
return
let currentSessionKey = self.sessionKey
guard currentSessionKey != nextSessionKey else {
// Best-effort only; history/send/health still work without push events.
return
}
nextSessionKey = currentSessionKey
continue
}
guard self.sessionKey != currentSessionKey else { return }
lastSubscribedSessionKey = currentSessionKey
let currentSessionKey = self.sessionKey
guard currentSessionKey != nextSessionKey else { return }
nextSessionKey = currentSessionKey
}
}
private func refreshPendingRunAfterForeground() async {
guard self.pendingRunCount > 0 else { return }
let context = self.beginHistoryRequest()
self.logDiagnostic(
"chat.ui foreground refresh sessionKey=\(self.sessionKey) "
"chat.ui foreground refresh sessionKey=\(context.session.key) "
+ "pending=\(self.pendingRunCount)")
await self.refreshHistoryAfterRun()
await self.pollHealthIfNeeded(force: true)
await self.refreshHistoryAfterRun(historyRequest: context)
await self.pollHealthIfNeeded(force: true, sessionSnapshot: context.session)
guard self.isCurrentSession(context.session) else { return }
if self.hasAssistantMessageAfterLatestUser() {
self.clearPendingRuns(reason: nil)
self.pendingToolCallsById = [:]
@@ -440,7 +559,7 @@ public final class OpenClawChatViewModel {
private func prunePendingLocalUserEchoMessageIDs() {
guard !self.pendingLocalUserEchoMessageIDsByRunID.isEmpty else { return }
let visibleMessageIDs = Set(self.messages.map(\.id))
let visibleMessageIDs = Set(messages.map(\.id))
self.pendingLocalUserEchoMessageIDsByRunID = self.pendingLocalUserEchoMessageIDsByRunID.filter {
self.pendingRuns.contains($0.key) && visibleMessageIDs.contains($0.value)
}
@@ -448,7 +567,7 @@ public final class OpenClawChatViewModel {
private func adoptPendingLocalUserEcho(incoming: OpenClawChatMessage) -> Bool {
guard let incomingKey = Self.userRefreshIdentityKey(for: incoming) else { return false }
guard let matchIndex = self.messages.lastIndex(where: { existing in
guard let matchIndex = messages.lastIndex(where: { existing in
self.pendingLocalUserEchoMessageIDsByRunID.values.contains(existing.id)
&& Self.userRefreshIdentityKey(for: existing) == incomingKey
}) else {
@@ -516,7 +635,8 @@ public final class OpenClawChatViewModel {
private static func reconcileRunRefreshMessages(
previous: [OpenClawChatMessage],
incoming: [OpenClawChatMessage]) -> [OpenClawChatMessage]
incoming: [OpenClawChatMessage],
pendingLocalUserEchoIDs: Set<UUID>) -> [OpenClawChatMessage]
{
guard !previous.isEmpty else { return incoming }
guard !incoming.isEmpty else { return previous }
@@ -532,42 +652,50 @@ public final class OpenClawChatViewModel {
var remainingIncomingUserRefreshCounts = countKeys(
reconciled.compactMap(Self.userRefreshIdentityKey(for:)))
var lastMatchedPreviousIndex: Int?
for (index, message) in previous.enumerated() {
if let key = Self.messageIdentityKey(for: message),
incomingIdentityKeys.contains(key)
{
lastMatchedPreviousIndex = index
// Exact history rows own their incoming user count before local echo matching.
// Otherwise repeated same-text sends can consume the canonical row twice.
for message in previous {
guard let identityKey = Self.messageIdentityKey(for: message),
incomingIdentityKeys.contains(identityKey),
let userKey = Self.userRefreshIdentityKey(for: message),
let remaining = remainingIncomingUserRefreshCounts[userKey],
remaining > 0
else {
continue
}
if let userKey = Self.userRefreshIdentityKey(for: message),
let remaining = remainingIncomingUserRefreshCounts[userKey],
remaining > 0
{
remainingIncomingUserRefreshCounts[userKey] = remaining - 1
lastMatchedPreviousIndex = index
}
remainingIncomingUserRefreshCounts[userKey] = remaining - 1
}
let trailingUserMessages = (lastMatchedPreviousIndex != nil
? previous.suffix(from: previous.index(after: lastMatchedPreviousIndex!))
: ArraySlice(previous))
.filter { message in
guard message.role.lowercased() == "user" else { return false }
guard let key = Self.userRefreshIdentityKey(for: message) else { return false }
let remaining = remainingIncomingUserRefreshCounts[key] ?? 0
if remaining > 0 {
remainingIncomingUserRefreshCounts[key] = remaining - 1
return false
}
return true
}
let lastCanonicalPreviousIndex = previous.lastIndex { message in
guard let identityKey = Self.messageIdentityKey(for: message) else { return false }
return incomingIdentityKeys.contains(identityKey)
}
let trailingLocalCandidates = lastCanonicalPreviousIndex.map { index in
previous[previous.index(after: index)...]
} ?? []
guard !trailingUserMessages.isEmpty else {
let pendingLocalUsers = previous.filter { message in
message.role.lowercased() == "user" && pendingLocalUserEchoIDs.contains(message.id)
}
let trailingLocalUsers = trailingLocalCandidates.filter { message in
guard message.role.lowercased() == "user" else { return false }
guard let identityKey = Self.messageIdentityKey(for: message) else { return true }
guard !incomingIdentityKeys.contains(identityKey) else { return false }
guard let userKey = Self.userRefreshIdentityKey(for: message) else { return true }
let remaining = remainingIncomingUserRefreshCounts[userKey] ?? 0
if remaining > 0 {
remainingIncomingUserRefreshCounts[userKey] = remaining - 1
return false
}
return true
}
let optimisticUserMessages = pendingLocalUsers + trailingLocalUsers
guard !optimisticUserMessages.isEmpty else {
return reconciled
}
for message in trailingUserMessages {
for message in optimisticUserMessages {
guard let messageTimestamp = message.timestamp else {
reconciled.append(message)
continue
@@ -646,14 +774,17 @@ public final class OpenClawChatViewModel {
return
}
let sessionKey = self.sessionKey
let sessionSnapshot = self.currentSessionSnapshot()
let sessionKey = sessionSnapshot.key
if !self.healthOK {
await self.pollHealthIfNeeded(force: true)
await self.pollHealthIfNeeded(force: true, sessionSnapshot: sessionSnapshot)
guard self.isCurrentSession(sessionSnapshot) else { return }
}
self.isSending = true
self.errorText = nil
defer { self.isSending = false }
let runId = UUID().uuidString
let messageText = trimmed.isEmpty && !self.attachments.isEmpty ? "See attached." : trimmed
let thinkingLevel = self.thinkingLevel
@@ -716,15 +847,17 @@ public final class OpenClawChatViewModel {
do {
await self.waitForPendingModelPatches(in: sessionKey)
guard self.isCurrentSession(sessionSnapshot) else { return }
self.logDiagnostic(
"chat.ui transport send start sessionKey=\(sessionKey) "
+ "localRunId=\(runId)")
let response = try await self.transport.sendMessage(
let response = try await transport.sendMessage(
sessionKey: sessionKey,
message: messageText,
thinking: thinkingLevel,
idempotencyKey: runId,
attachments: encodedAttachments)
guard self.isCurrentSession(sessionSnapshot) else { return }
self.logDiagnostic(
"chat.ui transport send accepted sessionKey=\(sessionKey) "
+ "localRunId=\(runId) remoteRunId=\(response.runId)")
@@ -735,21 +868,24 @@ public final class OpenClawChatViewModel {
self.pendingLocalUserEchoMessageIDsByRunID[response.runId] = pendingUserMessageID
self.armPendingRunTimeout(runId: response.runId)
}
await self.refreshHistoryAfterRun()
let historyContext = self.beginHistoryRequest(for: sessionSnapshot)
await self.refreshHistoryAfterRun(historyRequest: historyContext)
guard self.isCurrentSession(sessionSnapshot) else { return }
if !self.clearPendingRunIfAssistantMessagePresent(
runId: response.runId,
after: userMessageTimestamp)
{
self.armPostSendRefreshFallback(
runId: response.runId,
sessionKey: sessionKey,
sessionSnapshot: sessionSnapshot,
userMessageTimestamp: userMessageTimestamp)
self.armRunCompletionRefresh(
runId: response.runId,
sessionKey: sessionKey,
sessionSnapshot: sessionSnapshot,
userMessageTimestamp: userMessageTimestamp)
}
} catch {
guard self.isCurrentSession(sessionSnapshot) else { return }
self.pendingLocalUserEchoMessageIDsByRunID[runId] = nil
self.clearPendingRun(runId)
self.errorText = error.localizedDescription
@@ -758,8 +894,6 @@ public final class OpenClawChatViewModel {
+ "localRunId=\(runId) error=\(error.localizedDescription)")
chatUILogger.error("chat transport send failed \(error.localizedDescription, privacy: .public)")
}
self.isSending = false
}
private func performAbort() async {
@@ -768,7 +902,7 @@ public final class OpenClawChatViewModel {
self.isAborting = true
defer { self.isAborting = false }
let runIds = Array(self.pendingRuns)
let runIds = Array(pendingRuns)
for runId in runIds {
do {
try await self.transport.abortRun(sessionKey: self.sessionKey, runId: runId)
@@ -778,9 +912,10 @@ public final class OpenClawChatViewModel {
}
}
private func fetchSessions(limit: Int?) async {
private func fetchSessions(limit: Int?, sessionSnapshot: SessionSnapshot? = nil) async {
do {
let res = try await self.transport.listSessions(limit: limit)
let res = try await transport.listSessions(limit: limit)
if let sessionSnapshot, !self.isCurrentSession(sessionSnapshot) { return }
self.sessions = res.sessions
self.sessionDefaults = res.defaults
self.syncSelectedModel()
@@ -790,9 +925,11 @@ public final class OpenClawChatViewModel {
}
}
private func fetchModels() async {
private func fetchModels(sessionSnapshot: SessionSnapshot? = nil) async {
do {
self.modelChoices = try await self.transport.listModels()
let modelChoices = try await transport.listModels()
if let sessionSnapshot, !self.isCurrentSession(sessionSnapshot) { return }
self.modelChoices = modelChoices
self.syncSelectedModel()
} catch {
// Best-effort.
@@ -803,12 +940,19 @@ public final class OpenClawChatViewModel {
let next = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !next.isEmpty else { return }
guard next != self.sessionKey else { return }
self.advanceSessionGeneration()
self.sessionKey = next
if intent == .userInitiated {
self.onSessionChanged?(next)
}
self.modelSelectionID = Self.defaultModelSelectionID
Task { await self.bootstrap(sessionKey: next) }
self.messages = []
self.pendingLocalUserEchoMessageIDsByRunID.removeAll()
self.sessionId = nil
self.pendingToolCallsById = [:]
self.streamingAssistantText = nil
self.clearPendingRuns(reason: nil)
self.startBootstrap(sessionKey: next)
}
private func performStartNewSession() async {
@@ -816,7 +960,7 @@ public final class OpenClawChatViewModel {
let parentSessionKey = self.sessionKey
let next: String
do {
let created = try await self.transport.createSession(
let created = try await transport.createSession(
key: requested,
label: nil,
parentSessionKey: parentSessionKey)
@@ -832,6 +976,7 @@ public final class OpenClawChatViewModel {
self.errorText = error.localizedDescription
return
}
self.advanceSessionGeneration()
self.sessionKey = next
self.onSessionChanged?(next)
self.modelSelectionID = Self.defaultModelSelectionID
@@ -842,7 +987,7 @@ public final class OpenClawChatViewModel {
self.streamingAssistantText = nil
self.clearPendingRuns(reason: nil)
self.errorText = nil
await self.bootstrap()
self.startBootstrap()
}
private static func isUnsupportedCreateSessionError(_ error: Error) -> Bool {
@@ -854,17 +999,17 @@ public final class OpenClawChatViewModel {
private func performReset() async {
self.isLoading = true
self.errorText = nil
defer { self.isLoading = false }
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
}
await self.bootstrap()
self.startBootstrap()
}
private func performCompact() async {
@@ -874,7 +1019,7 @@ public final class OpenClawChatViewModel {
return
}
if let lastCompactAt,
Date().timeIntervalSince(lastCompactAt) < self.compactCooldown
Date().timeIntervalSince(lastCompactAt) < compactCooldown
{
self.errorText = "Please wait before compacting this session again."
return
@@ -884,13 +1029,13 @@ public final class OpenClawChatViewModel {
self.isLoading = true
self.errorText = nil
defer {
self.isLoading = false
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(
@@ -899,8 +1044,8 @@ public final class OpenClawChatViewModel {
return
}
self.lastCompactAt = Date()
await self.bootstrap()
lastCompactAt = Date()
self.startBootstrap()
}
private func performSelectThinkingLevel(_ level: String) async {
@@ -987,7 +1132,7 @@ public final class OpenClawChatViewModel {
}
private func endModelPatch(for sessionKey: String) {
let remaining = max(0, (self.inFlightModelPatchCountsBySession[sessionKey] ?? 0) - 1)
let remaining = max(0, (inFlightModelPatchCountsBySession[sessionKey] ?? 0) - 1)
if remaining == 0 {
self.inFlightModelPatchCountsBySession.removeValue(forKey: sessionKey)
let waiters = self.modelPatchWaitersBySession.removeValue(forKey: sessionKey) ?? []
@@ -1009,7 +1154,7 @@ public final class OpenClawChatViewModel {
private func syncThinkingLevelOptions() {
let currentSession = self.sessions.first(where: { $0.key == self.sessionKey })
var options = self.resolvedThinkingLevelOptions(for: currentSession)
if let current = Self.normalizedThinkingLevel(self.thinkingLevel) {
if let current = Self.normalizedThinkingLevel(thinkingLevel) {
options = Self.withCurrentThinkingOption(options, current: current)
}
self.thinkingLevelOptions = options
@@ -1027,7 +1172,7 @@ public final class OpenClawChatViewModel {
} ?? true
if defaultsMatch,
let levels = Self.normalizedThinkingLevelOptions(self.sessionDefaults?.thinkingLevels),
let levels = Self.normalizedThinkingLevelOptions(sessionDefaults?.thinkingLevels),
!levels.isEmpty
{
return levels
@@ -1038,7 +1183,7 @@ public final class OpenClawChatViewModel {
}
if defaultsMatch,
let options = Self.thinkingOptions(from: self.sessionDefaults?.thinkingOptions),
let options = Self.thinkingOptions(from: sessionDefaults?.thinkingOptions),
!options.isEmpty
{
return options
@@ -1148,7 +1293,7 @@ public final class OpenClawChatViewModel {
guard !trimmed.isEmpty else { return nil }
if let provider = Self.normalizedProvider(provider) {
let providerQualified = Self.providerQualifiedModelSelectionID(modelID: trimmed, provider: provider)
if let match = self.modelChoices.first(where: {
if let match = modelChoices.first(where: {
$0.selectionID == providerQualified ||
($0.modelID == trimmed && Self.normalizedProvider($0.provider) == provider)
}) {
@@ -1176,9 +1321,9 @@ public final class OpenClawChatViewModel {
private func generatedNewSessionKey() -> String {
let baseKey = "ios-\(UUID().uuidString.lowercased())"
guard let agentID = Self.agentID(fromSessionKey: self.sessionKey) ??
Self.agentID(fromSessionKey: self.resolvedMainSessionKey) ??
self.sessions.lazy.compactMap({ Self.agentID(fromSessionKey: $0.key) }).first
guard let agentID = Self.agentID(fromSessionKey: sessionKey) ??
Self.agentID(fromSessionKey: resolvedMainSessionKey) ??
sessions.lazy.compactMap({ Self.agentID(fromSessionKey: $0.key) }).first
else {
return baseKey
}
@@ -1213,11 +1358,12 @@ public final class OpenClawChatViewModel {
}
private func resolvedSessionModelIdentity(forSelectionID selectionID: String)
-> (modelID: String?, modelProvider: String?) {
guard let modelRef = self.modelRef(forSelectionID: selectionID) else {
-> (modelID: String?, modelProvider: String?)
{
guard let modelRef = modelRef(forSelectionID: selectionID) else {
return (nil, nil)
}
if let choice = self.modelChoices.first(where: { $0.selectionID == modelRef }) {
if let choice = modelChoices.first(where: { $0.selectionID == modelRef }) {
return (choice.modelID, Self.normalizedProvider(choice.provider))
}
return (modelRef, nil)
@@ -1238,7 +1384,7 @@ public final class OpenClawChatViewModel {
}
private func updateCurrentSessionThinkingLevel(_ thinkingLevel: String?, sessionKey: String) {
guard let index = self.sessions.firstIndex(where: { $0.key == sessionKey }) else { return }
guard let index = sessions.firstIndex(where: { $0.key == sessionKey }) else { return }
let current = self.sessions[index]
self.sessions[index] = OpenClawChatSessionEntry(
key: current.key,
@@ -1271,7 +1417,7 @@ public final class OpenClawChatViewModel {
sessionKey: String,
syncSelection: Bool)
{
if let index = self.sessions.firstIndex(where: { $0.key == sessionKey }) {
if let index = sessions.firstIndex(where: { $0.key == sessionKey }) {
let current = self.sessions[index]
self.sessions[index] = OpenClawChatSessionEntry(
key: current.key,
@@ -1327,7 +1473,8 @@ public final class OpenClawChatViewModel {
case let .health(ok):
self.healthOK = ok
case .tick:
Task { await self.pollHealthIfNeeded(force: false) }
let context = self.currentSessionSnapshot()
Task { await self.pollHealthIfNeeded(force: false, sessionSnapshot: context) }
case let .chat(chat):
self.handleChatEvent(chat)
case let .sessionMessage(message):
@@ -1337,9 +1484,10 @@ public final class OpenClawChatViewModel {
case .seqGap:
self.errorText = nil
self.clearPendingRuns(reason: nil)
let context = self.beginHistoryRequest()
Task {
await self.refreshHistoryAfterRun()
await self.pollHealthIfNeeded(force: true)
await self.refreshHistoryAfterRun(historyRequest: context)
await self.pollHealthIfNeeded(force: true, sessionSnapshot: context.session)
}
}
}
@@ -1394,7 +1542,8 @@ public final class OpenClawChatViewModel {
self.streamingAssistantText = nil
self.pendingToolCallsById = [:]
self.appendFinalChatMessageIfPresent(chat)
Task { await self.refreshHistoryAfterRun() }
let context = self.beginHistoryRequest()
Task { await self.refreshHistoryAfterRun(historyRequest: context) }
default:
break
}
@@ -1414,7 +1563,8 @@ public final class OpenClawChatViewModel {
self.pendingToolCallsById = [:]
self.streamingAssistantText = nil
self.appendFinalChatMessageIfPresent(chat)
Task { await self.refreshHistoryAfterRun() }
let context = self.beginHistoryRequest()
Task { await self.refreshHistoryAfterRun(historyRequest: context) }
default:
break
}
@@ -1532,7 +1682,8 @@ public final class OpenClawChatViewModel {
}
self.pendingToolCallsById = [:]
self.streamingAssistantText = nil
Task { await self.refreshHistoryAfterRun() }
let context = self.beginHistoryRequest()
Task { await self.refreshHistoryAfterRun(historyRequest: context) }
}
private static func lowercasedAgentEventString(_ value: AnyCodable?) -> String? {
@@ -1566,15 +1717,19 @@ public final class OpenClawChatViewModel {
return "Chat failed"
}
private func armPostSendRefreshFallback(runId: String, sessionKey: String, userMessageTimestamp: Double) {
private func armPostSendRefreshFallback(
runId: String,
sessionSnapshot: SessionSnapshot,
userMessageTimestamp: Double)
{
Task { [weak self] in
for delayMs in Self.postSendRefreshDelaysMs {
try? await Task.sleep(nanoseconds: delayMs * 1_000_000)
let shouldContinue = await self?.refreshIfPending(
runId: runId,
sessionKey: sessionKey,
sessionSnapshot: sessionSnapshot,
after: userMessageTimestamp,
diagnostic: "chat.ui refresh fallback sessionKey=\(sessionKey) "
diagnostic: "chat.ui refresh fallback sessionKey=\(sessionSnapshot.key) "
+ "runId=\(runId) delayMs=\(delayMs)")
guard shouldContinue == true else {
return
@@ -1583,36 +1738,45 @@ public final class OpenClawChatViewModel {
}
}
private func armRunCompletionRefresh(runId: String, sessionKey: String, userMessageTimestamp: Double) {
let timeoutMs = Int(self.pendingRunTimeoutMs)
private func armRunCompletionRefresh(
runId: String,
sessionSnapshot: SessionSnapshot,
userMessageTimestamp: Double)
{
let timeoutMs = Int(pendingRunTimeoutMs)
let transport = self.transport
Task { [weak self, transport] in
let observedCompletion = await transport.waitForRunCompletion(runId: runId, timeoutMs: timeoutMs)
guard observedCompletion else { return }
_ = await self?.refreshIfPending(
runId: runId,
sessionKey: sessionKey,
sessionSnapshot: sessionSnapshot,
after: userMessageTimestamp,
diagnostic: "chat.ui run completion refresh sessionKey=\(sessionKey) "
diagnostic: "chat.ui run completion refresh sessionKey=\(sessionSnapshot.key) "
+ "runId=\(runId)")
}
}
private func refreshIfPending(
runId: String,
sessionKey: String,
sessionSnapshot: SessionSnapshot,
after timestamp: Double,
diagnostic: String) async -> Bool
{
guard self.sessionKey == sessionKey, self.pendingRuns.contains(runId) else {
guard self.isCurrentSession(sessionSnapshot),
self.pendingRuns.contains(runId)
else {
return false
}
guard !self.clearPendingRunIfAssistantMessagePresent(runId: runId, after: timestamp) else {
return false
}
self.logDiagnostic(diagnostic)
await self.refreshHistoryAfterRun()
guard self.sessionKey == sessionKey, self.pendingRuns.contains(runId) else {
let historyContext = self.beginHistoryRequest(for: sessionSnapshot)
await self.refreshHistoryAfterRun(historyRequest: historyContext)
guard self.isCurrentSession(sessionSnapshot),
self.pendingRuns.contains(runId)
else {
return false
}
return !self.clearPendingRunIfAssistantMessagePresent(runId: runId, after: timestamp)
@@ -1628,13 +1792,84 @@ public final class OpenClawChatViewModel {
}
private func hasAssistantMessageAfterLatestUser() -> Bool {
guard let lastUserIndex = self.messages.lastIndex(where: { $0.role.lowercased() == "user" }) else {
Self.hasAssistantMessageAfterLatestUser(in: self.messages)
}
private static func hasUnansweredLatestUser(in messages: [OpenClawChatMessage]) -> Bool {
self.latestUserTurn(in: messages) != nil && !self.hasAssistantMessageAfterLatestUser(in: messages)
}
private static func latestUserTurn(in messages: [OpenClawChatMessage]) -> LatestUserTurn? {
guard let lastUserIndex = messages.lastIndex(where: { $0.role.lowercased() == "user" }) else {
return nil
}
guard let refreshKey = self.userRefreshIdentityKey(for: messages[lastUserIndex]) else {
return LatestUserTurn(
refreshKey: nil,
occurrence: 0,
timestamp: messages[lastUserIndex].timestamp)
}
let occurrence = messages[...lastUserIndex].reduce(into: 0) { count, message in
guard self.userRefreshIdentityKey(for: message) == refreshKey else { return }
count += 1
}
return LatestUserTurn(
refreshKey: refreshKey,
occurrence: occurrence,
timestamp: messages[lastUserIndex].timestamp)
}
private static func hasAnsweredUser(
_ user: LatestUserTurn,
in messages: [OpenClawChatMessage])
-> Bool
{
guard let refreshKey = user.refreshKey else { return false }
var occurrence = 0
var latestMatchingUserIndex: [OpenClawChatMessage].Index?
for (index, message) in messages.enumerated() {
guard self.userRefreshIdentityKey(for: message) == refreshKey else { continue }
occurrence += 1
latestMatchingUserIndex = index
guard occurrence == user.occurrence else { continue }
let nextIndex = messages.index(after: index)
guard nextIndex < messages.endIndex else { return false }
return messages[nextIndex...].contains { message in
guard message.role.lowercased() == "assistant" else { return false }
let text = message.content.compactMap(\.text).joined(separator: "\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
return !text.isEmpty || message.errorMessage != nil
}
}
guard let latestMatchingUserIndex,
messages.lastIndex(where: { $0.role.lowercased() == "user" }) == latestMatchingUserIndex
else {
return false
}
guard lastUserIndex < self.messages.index(before: self.messages.endIndex) else {
if let requestTimestamp = user.timestamp,
let latestTimestamp = messages[latestMatchingUserIndex].timestamp,
latestTimestamp < requestTimestamp
{
return false
}
return self.messages[self.messages.index(after: lastUserIndex)...].contains { message in
let nextIndex = messages.index(after: latestMatchingUserIndex)
guard nextIndex < messages.endIndex else { return false }
return messages[nextIndex...].contains { message in
guard message.role.lowercased() == "assistant" else { return false }
let text = message.content.compactMap(\.text).joined(separator: "\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
return !text.isEmpty || message.errorMessage != nil
}
}
private static func hasAssistantMessageAfterLatestUser(in messages: [OpenClawChatMessage]) -> Bool {
guard let lastUserIndex = messages.lastIndex(where: { $0.role.lowercased() == "user" }) else {
return false
}
guard lastUserIndex < messages.index(before: messages.endIndex) else {
return false
}
return messages[messages.index(after: lastUserIndex)...].contains { message in
guard message.role.lowercased() == "assistant" else { return false }
let text = message.content.compactMap(\.text).joined(separator: "\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -1654,22 +1889,18 @@ public final class OpenClawChatViewModel {
}
}
private func refreshHistoryAfterRun() async {
@discardableResult
private func refreshHistoryAfterRun(historyRequest request: HistoryRequest? = nil) async -> Bool {
let request = request ?? self.beginHistoryRequest()
do {
let payload = try await self.transport.requestHistory(sessionKey: self.sessionKey)
self.messages = Self.reconcileRunRefreshMessages(
previous: self.messages,
incoming: Self.decodeMessages(payload.messages ?? []))
self.prunePendingLocalUserEchoMessageIDs()
self.sessionId = payload.sessionId
if !self.prefersExplicitThinkingLevel,
let level = Self.normalizedThinkingLevel(payload.thinkingLevel)
{
self.thinkingLevel = level
self.syncThinkingLevelOptions()
}
let payload = try await transport.requestHistory(sessionKey: request.session.key)
return self.applyHistoryPayload(
payload,
for: request,
preservingOptimisticLocalMessages: true)
} catch {
chatUILogger.error("refresh history failed \(error.localizedDescription, privacy: .public)")
return false
}
}
@@ -1704,7 +1935,7 @@ public final class OpenClawChatViewModel {
}
private func clearPendingRuns(reason: String?) {
let runIds = Array(self.pendingRuns)
let runIds = Array(pendingRuns)
for runId in self.pendingRuns {
self.pendingRunTimeoutTasks[runId]?.cancel()
}
@@ -1721,15 +1952,17 @@ public final class OpenClawChatViewModel {
}
}
private func pollHealthIfNeeded(force: Bool) async {
if !force, let last = self.lastHealthPollAt, Date().timeIntervalSince(last) < 10 {
private func pollHealthIfNeeded(force: Bool, sessionSnapshot: SessionSnapshot? = nil) async {
if !force, let last = lastHealthPollAt, Date().timeIntervalSince(last) < 10 {
return
}
self.lastHealthPollAt = Date()
do {
let ok = try await self.transport.requestHealth(timeoutMs: 5000)
let ok = try await transport.requestHealth(timeoutMs: 5000)
if let sessionSnapshot, !self.isCurrentSession(sessionSnapshot) { return }
self.healthOK = ok
} catch {
if let sessionSnapshot, !self.isCurrentSession(sessionSnapshot) { return }
self.healthOK = false
}
}