diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift index 365d8c60b28..d37f8616479 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift @@ -40,11 +40,19 @@ public final class OpenClawChatViewModel { @ObservationIgnored private nonisolated(unsafe) var eventTask: Task? + @ObservationIgnored + private nonisolated(unsafe) var bootstrapTask: Task? private var pendingRuns = Set() { 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] = [:] @@ -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) -> [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 } } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift index 072adf389a8..91950ab8000 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift @@ -97,6 +97,7 @@ private func makeViewModel( historyResponses: [OpenClawChatHistoryPayload], sessionsResponses: [OpenClawChatSessionsListResponse] = [], modelResponses: [[OpenClawChatModelChoice]] = [], + requestHistoryHook: (@Sendable (String) async throws -> Void)? = nil, setActiveSessionHook: (@Sendable (String) async throws -> Void)? = nil, createSessionHook: (@Sendable (String, String?) async throws -> Void)? = nil, resetSessionHook: (@Sendable (String) async throws -> Void)? = nil, @@ -114,6 +115,7 @@ private func makeViewModel( historyResponses: historyResponses, sessionsResponses: sessionsResponses, modelResponses: modelResponses, + requestHistoryHook: requestHistoryHook, setActiveSessionHook: setActiveSessionHook, createSessionHook: createSessionHook, resetSessionHook: resetSessionHook, @@ -159,6 +161,13 @@ private func waitForLastSentRunId(_ transport: TestChatTransport) async throws - return try #require(await transport.lastSentRunId()) } +private func waitForSentRunId(after sentRunCount: Int, _ transport: TestChatTransport) async throws -> String { + try await waitUntil("transport send called") { + await transport.sentRunIds().count > sentRunCount + } + return try #require(await transport.sentRunIds().last) +} + @discardableResult private func sendMessageAndEmitFinal( transport: TestChatTransport, @@ -166,10 +175,15 @@ private func sendMessageAndEmitFinal( text: String, sessionKey: String = "main") async throws -> String { + let sentRunCount = await transport.sentRunIds().count await sendUserMessage(vm, text: text) - try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } } + let runId = try await waitForSentRunId(after: sentRunCount, transport) + try await waitUntil("send is pending or refreshed") { + await MainActor.run { + vm.pendingRunCount == 1 || (!vm.isSending && vm.pendingRunCount == 0) + } + } - let runId = try await waitForLastSentRunId(transport) transport.emit( .chat( OpenClawChatEventPayload( @@ -278,6 +292,10 @@ private actor AsyncCounter { self.value += 1 return self.value } + + func current() -> Int { + self.value + } } private actor SessionSubscribeGate { @@ -322,6 +340,7 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor private let historyResponses: [OpenClawChatHistoryPayload] private let sessionsResponses: [OpenClawChatSessionsListResponse] private let modelResponses: [[OpenClawChatModelChoice]] + private let requestHistoryHook: (@Sendable (String) async throws -> Void)? private let setActiveSessionHook: (@Sendable (String) async throws -> Void)? private let createSessionHook: (@Sendable (String, String?) async throws -> Void)? private let resetSessionHook: (@Sendable (String) async throws -> Void)? @@ -338,6 +357,7 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor historyResponses: [OpenClawChatHistoryPayload], sessionsResponses: [OpenClawChatSessionsListResponse] = [], modelResponses: [[OpenClawChatModelChoice]] = [], + requestHistoryHook: (@Sendable (String) async throws -> Void)? = nil, setActiveSessionHook: (@Sendable (String) async throws -> Void)? = nil, createSessionHook: (@Sendable (String, String?) async throws -> Void)? = nil, resetSessionHook: (@Sendable (String) async throws -> Void)? = nil, @@ -350,6 +370,7 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor self.historyResponses = historyResponses self.sessionsResponses = sessionsResponses self.modelResponses = modelResponses + self.requestHistoryHook = requestHistoryHook self.setActiveSessionHook = setActiveSessionHook self.createSessionHook = createSessionHook self.resetSessionHook = resetSessionHook @@ -390,8 +411,10 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor } func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload { - let idx = await self.state.historyCallCount - await self.state.setHistoryCallCount(idx + 1) + let idx = await self.state.nextHistoryCallIndex() + if let requestHistoryHook { + try await requestHistoryHook(sessionKey) + } if idx < self.historyResponses.count { return self.historyResponses[idx] } @@ -420,8 +443,7 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor } func listSessions(limit _: Int?) async throws -> OpenClawChatSessionsListResponse { - let idx = await self.state.sessionsCallCount - await self.state.setSessionsCallCount(idx + 1) + let idx = await self.state.nextSessionsCallIndex() if idx < self.sessionsResponses.count { return self.sessionsResponses[idx] } @@ -434,8 +456,7 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor } func listModels() async throws -> [OpenClawChatModelChoice] { - let idx = await self.state.modelsCallCount - await self.state.setModelsCallCount(idx + 1) + let idx = await self.state.nextModelsCallIndex() if idx < self.modelResponses.count { return self.modelResponses[idx] } @@ -471,8 +492,7 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor } func requestHealth(timeoutMs _: Int) async throws -> Bool { - let idx = await self.state.healthCallCount - await self.state.setHealthCallCount(idx + 1) + let idx = await self.state.nextHealthCallIndex() if idx < self.healthResponses.count { return self.healthResponses[idx] } @@ -544,20 +564,24 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor } extension TestChatTransportState { - fileprivate func setHistoryCallCount(_ v: Int) { - self.historyCallCount = v + fileprivate func nextHistoryCallIndex() -> Int { + defer { self.historyCallCount += 1 } + return self.historyCallCount } - fileprivate func setSessionsCallCount(_ v: Int) { - self.sessionsCallCount = v + fileprivate func nextSessionsCallIndex() -> Int { + defer { self.sessionsCallCount += 1 } + return self.sessionsCallCount } - fileprivate func setModelsCallCount(_ v: Int) { - self.modelsCallCount = v + fileprivate func nextModelsCallIndex() -> Int { + defer { self.modelsCallCount += 1 } + return self.modelsCallCount } - fileprivate func setHealthCallCount(_ v: Int) { - self.healthCallCount = v + fileprivate func nextHealthCallIndex() -> Int { + defer { self.healthCallCount += 1 } + return self.healthCallCount } fileprivate func activeSessionKeysAppend(_ v: String) { @@ -609,8 +633,8 @@ extension TestChatTransportState { } } -@Suite struct ChatViewModelTests { - @Test func displaysErrorMessageFallbackOnlyForAssistantErrorTurns() throws { +struct ChatViewModelTests { + @Test func `displays error message fallback only for assistant error turns`() throws { func decodeMessage(role: String, stopReason: String, contentText: String? = nil) throws -> OpenClawChatMessage { let contentJSON = contentText.map { #"[{"type":"text","text":"\#($0)"}]"# } ?? "[]" let data = """ @@ -680,7 +704,7 @@ extension TestChatTransportState { errorMessage: toolUseAssistant.errorMessage) == nil) } - @Test func streamsAssistantAndClearsOnFinal() async throws { + @Test func `streams assistant and clears on final`() async throws { let sessionId = "sess-main" let history1 = historyPayload(sessionId: sessionId) let history2 = historyPayload( @@ -725,7 +749,7 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.pendingToolCalls.isEmpty }) } - @Test func rendersFinalChatEventMessageWhenHistoryIsStale() async throws { + @Test func `renders final chat event message when history is stale`() async throws { let sessionId = "sess-main" let history = historyPayload(sessionId: sessionId) let (transport, vm) = await makeViewModel(historyResponses: [history, history]) @@ -758,9 +782,9 @@ extension TestChatTransportState { } } - @Test func completionWaitRefreshesHistoryAndClearsPendingRun() async throws { + @Test func `completion wait refreshes history and clears pending run`() async throws { let sessionId = "sess-main" - let now = Date().timeIntervalSince1970 * 1000 + let now = (Date().timeIntervalSince1970 * 1000) + 10000 let history1 = historyPayload(sessionId: sessionId) let history2 = historyPayload(sessionId: sessionId, messages: []) let history3 = historyPayload( @@ -794,9 +818,9 @@ extension TestChatTransportState { } } - @Test func agentLifecycleEndRefreshesHistoryAndClearsPendingRun() async throws { + @Test func `agent lifecycle end refreshes history and clears pending run`() async throws { let sessionId = "sess-main" - let now = Date().timeIntervalSince1970 * 1000 + let now = (Date().timeIntervalSince1970 * 1000) + 10000 let history1 = historyPayload(sessionId: sessionId) let history2 = historyPayload(sessionId: sessionId, messages: []) let history3 = historyPayload( @@ -831,7 +855,7 @@ extension TestChatTransportState { } } - @Test func pendingRunBlocksSecondMainSend() async throws { + @Test func `pending run blocks second main send`() async throws { let sessionId = "sess-main" let history = historyPayload(sessionId: sessionId, messages: []) let (transport, vm) = await makeViewModel(historyResponses: [history, history]) @@ -856,7 +880,7 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.input } == "second") } - @Test func keepsOptimisticUserMessageWhenFinalRefreshReturnsOnlyAssistantHistory() async throws { + @Test func `keeps optimistic user message when final refresh returns only assistant history`() async throws { let sessionId = "sess-main" let now = Date().timeIntervalSince1970 * 1000 let history1 = historyPayload(sessionId: sessionId) @@ -887,7 +911,7 @@ extension TestChatTransportState { } } - @Test func keepsOptimisticUserMessageWhenFinalRefreshHistoryIsTemporarilyEmpty() async throws { + @Test func `keeps optimistic user message when final refresh history is temporarily empty`() async throws { let sessionId = "sess-main" let history1 = historyPayload(sessionId: sessionId) let history2 = historyPayload(sessionId: sessionId, messages: []) @@ -909,7 +933,7 @@ extension TestChatTransportState { } } - @Test func doesNotDuplicateUserMessageWhenRefreshReturnsCanonicalTimestamp() async throws { + @Test func `does not duplicate user message when refresh returns canonical timestamp`() async throws { let sessionId = "sess-main" let now = Date().timeIntervalSince1970 * 1000 let history1 = historyPayload(sessionId: sessionId) @@ -948,7 +972,7 @@ extension TestChatTransportState { } } - @Test func preservesRepeatedOptimisticUserMessagesWithIdenticalContentDuringRefresh() async throws { + @Test func `preserves repeated optimistic user messages with identical content during refresh`() async throws { let sessionId = "sess-main" let now = Date().timeIntervalSince1970 * 1000 let history1 = historyPayload(sessionId: sessionId) @@ -971,6 +995,15 @@ extension TestChatTransportState { transport: transport, vm: vm, text: "retry") + try await waitUntil("first retry completes") { + await MainActor.run { + vm.pendingRunCount == 0 && + vm.messages.contains { message in + message.role == "assistant" && + message.content.compactMap(\.text).joined(separator: "\n") == "first answer" + } + } + } try await sendMessageAndEmitFinal( transport: transport, vm: vm, @@ -991,7 +1024,100 @@ extension TestChatTransportState { } } - @Test func acceptsCanonicalSessionKeyEventsForOwnPendingRun() async throws { + @Test func `run refresh does not resurrect old user turns omitted by bounded history`() async throws { + let sessionId = "sess-main" + let now = Date().timeIntervalSince1970 * 1000 + let oldMessages = [ + chatTextMessage(role: "user", text: "old question", timestamp: now - 2000), + chatTextMessage(role: "assistant", text: "old answer", timestamp: now - 1000), + ] + let boundedRefreshMessages = [ + chatTextMessage(role: "user", text: "current question", timestamp: now + 5000), + chatTextMessage(role: "assistant", text: "current answer", timestamp: now + 6000), + ] + let (transport, vm) = await makeViewModel( + historyResponses: [ + historyPayload(sessionId: sessionId, messages: oldMessages), + historyPayload(sessionId: sessionId, messages: boundedRefreshMessages), + ]) + try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId) + try await sendMessageAndEmitFinal( + transport: transport, + vm: vm, + text: "current question") + + try await waitUntil("bounded refresh replaces old history") { + await MainActor.run { + let texts = vm.messages.map { message in + message.content.compactMap(\.text).joined(separator: "\n") + } + return texts.contains("current answer") && + !texts.contains("old question") && + !texts.contains("old answer") + } + } + } + + @Test @MainActor func `bounded repeated same text reply invalidates older stale refresh`() async throws { + let sessionId = "sess-main" + let staleRefreshGate = SessionSubscribeGate() + let historyCount = AsyncCounter() + let staleRefreshReleasedCount = AsyncCounter() + let now = (Date().timeIntervalSince1970 * 1000) + 10000 + let firstTurn = [ + chatTextMessage(role: "user", text: "retry", timestamp: now), + chatTextMessage(role: "assistant", text: "first answer", timestamp: now + 1), + ] + let latestBoundedTurn = [ + chatTextMessage(role: "user", text: "retry", timestamp: now + 2), + chatTextMessage(role: "assistant", text: "second answer", timestamp: now + 3), + ] + let (transport, vm) = await makeViewModel( + historyResponses: [ + historyPayload(sessionId: sessionId, messages: firstTurn), + historyPayload(sessionId: sessionId, messages: firstTurn), + historyPayload(sessionId: sessionId, messages: latestBoundedTurn), + ], + requestHistoryHook: { sessionKey in + guard sessionKey == "main" else { return } + let count = await historyCount.increment() + if count == 2 { + await staleRefreshGate.wait() + _ = await staleRefreshReleasedCount.increment() + } + }) + try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId) + + transport.emit(OpenClawChatTransportEvent.seqGap) + try await waitUntil("stale refresh is in flight") { + await historyCount.current() == 2 + } + + vm.input = "retry" + vm.send() + _ = try await waitForLastSentRunId(transport) + try await waitUntil("bounded second answer applies") { + await MainActor.run { + vm.sessionId == sessionId && + vm.messages.contains { message in + message.content.contains { $0.text == "second answer" } + } + } + } + + await staleRefreshGate.release() + try await waitUntil("stale refresh resumes") { + await staleRefreshReleasedCount.current() == 1 + } + + #expect(await MainActor.run { + vm.messages.contains { message in + message.content.contains { $0.text == "second answer" } + } + }) + } + + @Test func `accepts canonical session key events for own pending run`() async throws { let history1 = historyPayload() let history2 = historyPayload( messages: [ @@ -1022,7 +1148,7 @@ extension TestChatTransportState { } } - @Test func surfacesAssistantErrorMessageAfterOwnRunRefresh() async throws { + @Test func `surfaces assistant error message after own run refresh`() async throws { let now = Date().timeIntervalSince1970 * 1000 let history1 = historyPayload() let history2 = historyPayload( @@ -1067,7 +1193,7 @@ extension TestChatTransportState { } } - @Test func acceptsCanonicalSessionKeyEventsForExternalRuns() async throws { + @Test func `accepts canonical session key events for external runs`() async throws { let now = Date().timeIntervalSince1970 * 1000 let history1 = historyPayload(messages: [chatTextMessage(role: "user", text: "first", timestamp: now)]) let history2 = historyPayload( @@ -1095,7 +1221,7 @@ extension TestChatTransportState { } } - @Test func appendsExternalSessionUserMessageForActiveSession() async throws { + @Test func `appends external session user message for active session`() async throws { let now = Date().timeIntervalSince1970 * 1000 let (transport, vm) = await makeViewModel( sessionKey: "agent:aiden:main", @@ -1131,7 +1257,7 @@ extension TestChatTransportState { } } - @Test func appendsGlobalSessionUserMessageForSelectedAgent() async throws { + @Test func `appends global session user message for selected agent`() async throws { let now = Date().timeIntervalSince1970 * 1000 let (transport, vm) = await makeViewModel( sessionKey: "agent:work:global", @@ -1168,7 +1294,7 @@ extension TestChatTransportState { } } - @Test func ignoresGlobalSessionUserMessageForDifferentAgent() async throws { + @Test func `ignores global session user message for different agent`() async throws { let now = Date().timeIntervalSince1970 * 1000 let (transport, vm) = await makeViewModel( sessionKey: "agent:work:global", @@ -1200,7 +1326,7 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.messages.isEmpty }) } - @Test func ignoresAgentMainSessionMessageForDifferentCurrentMainAlias() async throws { + @Test func `ignores agent main session message for different current main alias`() async throws { let now = Date().timeIntervalSince1970 * 1000 let (transport, vm) = await makeViewModel(historyResponses: [historyPayload()]) @@ -1229,7 +1355,7 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.messages.isEmpty }) } - @Test func appendsExternalSessionAssistantMessageWhileRunPending() async throws { + @Test func `appends external session assistant message while run pending`() async throws { let now = Date().timeIntervalSince1970 * 1000 let (transport, vm) = await makeViewModel(historyResponses: [historyPayload()]) @@ -1267,7 +1393,7 @@ extension TestChatTransportState { } } - @Test func dedupesGatewayEchoOfLocalUserMessage() async throws { + @Test func `dedupes gateway echo of local user message`() async throws { let (transport, vm) = await makeViewModel(historyResponses: [historyPayload()]) await MainActor.run { vm.load() } @@ -1296,19 +1422,19 @@ extension TestChatTransportState { fileName: nil, content: nil), ], - timestamp: Date().timeIntervalSince1970 * 1000 + 5_000), + timestamp: Date().timeIntervalSince1970 * 1000 + 5000), messageId: "srv-echo-1", messageSeq: 1))) try await Task.sleep(nanoseconds: 50_000_000) #expect(await MainActor.run { - vm.messages.filter { msg in + vm.messages.count(where: { msg in msg.role == "user" && msg.content.first?.text == "echo me" - }.count == 1 + }) == 1 }) } - @Test func appendsSameContentUserTranscriptWhenItIsNotLocalEcho() async throws { + @Test func `appends same content user transcript when it is not local echo`() async throws { let now = Date().timeIntervalSince1970 * 1000 let (transport, vm) = await makeViewModel( historyResponses: [ @@ -1336,20 +1462,20 @@ extension TestChatTransportState { fileName: nil, content: nil), ], - timestamp: now + 1_000), + timestamp: now + 1000), messageId: "msg-repeat-2", messageSeq: 2))) try await waitUntil("repeated user transcript appended") { await MainActor.run { - vm.messages.filter { msg in + vm.messages.count(where: { msg in msg.role == "user" && msg.content.first?.text == "repeat" - }.count == 2 + }) == 2 } } } - @Test func ignoresExternalSessionUserMessageForOtherSession() async throws { + @Test func `ignores external session user message for other session`() async throws { let now = Date().timeIntervalSince1970 * 1000 let (transport, vm) = await makeViewModel(historyResponses: [historyPayload()]) @@ -1378,7 +1504,7 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.messages.isEmpty }) } - @Test func preservesMessageIDsAcrossHistoryRefreshes() async throws { + @Test func `preserves message I ds across history refreshes`() async throws { let now = Date().timeIntervalSince1970 * 1000 let history1 = historyPayload(messages: [chatTextMessage(role: "user", text: "hello", timestamp: now)]) let history2 = historyPayload( @@ -1400,7 +1526,7 @@ extension TestChatTransportState { #expect(firstIdAfter == firstIdBefore) } - @Test func clearsStreamingOnExternalFinalEvent() async throws { + @Test func `clears streaming on external final event`() async throws { let sessionId = "sess-main" let history = historyPayload(sessionId: sessionId) let (transport, vm) = await makeViewModel(historyResponses: [history, history]) @@ -1420,7 +1546,7 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.pendingToolCalls.isEmpty }) } - @Test func seqGapClearsPendingRunsAndAutoRefreshesHistory() async throws { + @Test func `seq gap clears pending runs and auto refreshes history`() async throws { let now = Date().timeIntervalSince1970 * 1000 let history1 = historyPayload() let history2 = historyPayload(messages: [chatTextMessage( @@ -1446,7 +1572,7 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.errorText == nil }) } - @Test func sessionChoicesPreferMainAndRecent() async throws { + @Test func `session choices prefer main and recent`() async throws { let now = Date().timeIntervalSince1970 * 1000 let recent = now - (2 * 60 * 60 * 1000) let recentOlder = now - (5 * 60 * 60 * 1000) @@ -1472,7 +1598,7 @@ extension TestChatTransportState { #expect(keys == ["main", "recent-1", "recent-2"]) } - @Test func sessionChoicesIncludeCurrentWhenMissing() async throws { + @Test func `session choices include current when missing`() async throws { let now = Date().timeIntervalSince1970 * 1000 let recent = now - (30 * 60 * 1000) let history = historyPayload(sessionKey: "custom", sessionId: "sess-custom") @@ -1496,7 +1622,7 @@ extension TestChatTransportState { #expect(keys == ["main", "custom"]) } - @Test func sessionChoicesUseResolvedMainSessionKeyInsteadOfLiteralMain() async throws { + @Test func `session choices use resolved main session key instead of literal main`() async throws { let now = Date().timeIntervalSince1970 * 1000 let recent = now - (30 * 60 * 1000) let recentOlder = now - (90 * 60 * 1000) @@ -1544,7 +1670,7 @@ extension TestChatTransportState { #expect(keys == ["Luke’s MacBook Pro", "recent-1"]) } - @Test func sessionChoicesHideInternalOnboardingSession() async throws { + @Test func `session choices hide internal onboarding session`() async throws { let now = Date().timeIntervalSince1970 * 1000 let recent = now - (2 * 60 * 1000) let recentOlder = now - (5 * 60 * 1000) @@ -1611,7 +1737,7 @@ extension TestChatTransportState { #expect(keys == ["agent:main:main"]) } - @Test func newTriggerStartsFreshAgentSessionWithoutAdminReset() async throws { + @Test func `new trigger starts fresh agent session without admin reset`() async throws { let before = historyPayload( messages: [ chatTextMessage(role: "assistant", text: "before new", timestamp: 1), @@ -1659,7 +1785,7 @@ extension TestChatTransportState { } } - @Test func newTriggerFallsBackToResetWhenCreateSessionIsUnsupported() async throws { + @Test func `new trigger falls back to reset when create session is unsupported`() async throws { let before = historyPayload( messages: [ chatTextMessage(role: "assistant", text: "before new", timestamp: 1), @@ -1698,7 +1824,7 @@ extension TestChatTransportState { #expect(await transport.lastSentRunId() == nil) } - @Test func sendAttemptsRequestWhenCachedHealthIsStaleFalse() async throws { + @Test func `send attempts request when cached health is stale false`() async throws { let (transport, vm) = await makeViewModel( historyResponses: [historyPayload()], healthResponses: [false]) @@ -1715,7 +1841,7 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.errorText } == nil) } - @Test func resetTriggerResetsSessionAndReloadsHistory() async throws { + @Test func `reset trigger resets session and reloads history`() async throws { let before = historyPayload( messages: [ chatTextMessage(role: "assistant", text: "before reset", timestamp: 1), @@ -1745,7 +1871,7 @@ extension TestChatTransportState { #expect(await transport.lastSentRunId() == nil) } - @Test func compactTriggerCompactsSessionAndReloadsHistory() async throws { + @Test func `compact trigger compacts session and reloads history`() async throws { let before = historyPayload( messages: [ chatTextMessage(role: "assistant", text: "before compact", timestamp: 1), @@ -1775,7 +1901,7 @@ extension TestChatTransportState { #expect(await transport.lastSentRunId() == nil) } - @Test func compactTriggerShowsGenericErrorMessageOnFailure() async throws { + @Test func `compact trigger shows generic error message on failure`() async throws { let history = historyPayload() let (transport, vm) = await makeViewModel( historyResponses: [history], @@ -1798,7 +1924,7 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.errorText } == "Unable to compact the session. Please try again.") } - @Test func compactTriggerIgnoresConcurrentAndImmediateRepeatRequests() async throws { + @Test func `compact trigger ignores concurrent and immediate repeat requests`() async throws { let before = historyPayload( messages: [ chatTextMessage(role: "assistant", text: "before compact", timestamp: 1), @@ -1842,7 +1968,7 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.errorText } == "Please wait before compacting this session again.") } - @Test func compactTriggerAllowsImmediateRetryAfterFailure() async throws { + @Test func `compact trigger allows immediate retry after failure`() async throws { let history = historyPayload() let attemptCount = AsyncCounter() let (transport, vm) = await makeViewModel( @@ -1879,7 +2005,7 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.errorText } == nil) } - @Test func bootstrapsModelSelectionFromSessionAndDefaults() async throws { + @Test func `bootstraps model selection from session and defaults`() async throws { let now = Date().timeIntervalSince1970 * 1000 let history = historyPayload() let sessions = OpenClawChatSessionsListResponse( @@ -1907,7 +2033,7 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.defaultModelLabel } == "Default: openai/gpt-4.1-mini") } - @Test func selectingDefaultModelPatchesNilAndUpdatesSelection() async throws { + @Test func `selecting default model patches nil and updates selection`() async throws { let now = Date().timeIntervalSince1970 * 1000 let history = historyPayload() let sessions = OpenClawChatSessionsListResponse( @@ -1940,7 +2066,7 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.modelSelectionID } == OpenClawChatViewModel.defaultModelSelectionID) } - @Test func selectingProviderQualifiedModelDisambiguatesDuplicateModelIDs() async throws { + @Test func `selecting provider qualified model disambiguates duplicate model I ds`() async throws { let now = Date().timeIntervalSince1970 * 1000 let history = historyPayload() let sessions = OpenClawChatSessionsListResponse( @@ -1973,7 +2099,7 @@ extension TestChatTransportState { } } - @Test func slashModelIDsStayProviderQualifiedInSelectionAndPatch() async throws { + @Test func `slash model I ds stay provider qualified in selection and patch`() async throws { let now = Date().timeIntervalSince1970 * 1000 let history = historyPayload() let sessions = OpenClawChatSessionsListResponse( @@ -2006,7 +2132,7 @@ extension TestChatTransportState { } } - @Test func staleModelPatchCompletionsDoNotOverwriteNewerSelection() async throws { + @Test func `stale model patch completions do not overwrite newer selection`() async throws { let now = Date().timeIntervalSince1970 * 1000 let history = historyPayload() let sessions = OpenClawChatSessionsListResponse( @@ -2049,7 +2175,7 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.modelProvider } == "openai") } - @Test func sendWaitsForInFlightModelPatchToFinish() async throws { + @Test func `send waits for in flight model patch to finish`() async throws { let now = Date().timeIntervalSince1970 * 1000 let history = historyPayload() let sessions = OpenClawChatSessionsListResponse( @@ -2102,7 +2228,7 @@ extension TestChatTransportState { #expect(await transport.sentThinkingLevels() == ["off"]) } - @Test func failedLatestModelSelectionDoesNotReplayAfterOlderCompletionFinishes() async throws { + @Test func `failed latest model selection does not replay after older completion finishes`() async throws { let now = Date().timeIntervalSince1970 * 1000 let history = historyPayload() let sessions = OpenClawChatSessionsListResponse( @@ -2152,7 +2278,7 @@ extension TestChatTransportState { #expect(await transport.patchedModels() == ["openai/gpt-5.4", "openai/gpt-5.4-pro"]) } - @Test func failedLatestModelSelectionRestoresEarlierSuccessWithoutReplay() async throws { + @Test func `failed latest model selection restores earlier success without replay`() async throws { let now = Date().timeIntervalSince1970 * 1000 let history = historyPayload() let sessions = OpenClawChatSessionsListResponse( @@ -2201,7 +2327,7 @@ extension TestChatTransportState { #expect(await transport.patchedModels() == ["openai/gpt-5.4", "openai/gpt-5.4-pro"]) } - @Test @MainActor func switchSessionNotifiesSessionChangedCallback() async throws { + @Test @MainActor func `switch session notifies session changed callback`() async throws { var changedSessionKeys: [String] = [] let (_, vm) = await makeViewModel( historyResponses: [ @@ -2220,7 +2346,7 @@ extension TestChatTransportState { #expect(changedSessionKeys == ["other"]) } - @Test @MainActor func syncSessionDoesNotNotifySessionChangedCallback() async throws { + @Test @MainActor func `sync session does not notify session changed callback`() async throws { var changedSessionKeys: [String] = [] let (_, vm) = await makeViewModel( historyResponses: [ @@ -2239,7 +2365,880 @@ extension TestChatTransportState { #expect(changedSessionKeys.isEmpty) } - @Test @MainActor func staleSyncBootstrapRestoresCurrentActiveSessionSubscription() async throws { + @Test @MainActor func `refresh ignores late history from canceled bootstrap for same session`() async throws { + let staleHistoryGate = SessionSubscribeGate() + let mainHistoryCount = AsyncCounter() + let staleHistoryReleasedCount = AsyncCounter() + let (_, vm) = await makeViewModel( + historyResponses: [ + historyPayload( + sessionKey: "main", + sessionId: "sess-stale-load", + messages: [chatTextMessage(role: "assistant", text: "stale load", timestamp: 1)]), + historyPayload( + sessionKey: "main", + sessionId: "sess-current-refresh", + messages: [chatTextMessage(role: "assistant", text: "current refresh", timestamp: 2)]), + ], + requestHistoryHook: { sessionKey in + guard sessionKey == "main" else { return } + let count = await mainHistoryCount.increment() + if count == 1 { + await staleHistoryGate.wait() + _ = await staleHistoryReleasedCount.increment() + } + }) + + vm.load() + try await waitUntil("first bootstrap history request is in flight") { + await mainHistoryCount.current() == 1 + } + + vm.refresh() + try await waitUntil("refresh bootstrap wins") { + await MainActor.run { + vm.sessionId == "sess-current-refresh" && + vm.messages.contains { message in + message.content.contains { $0.text == "current refresh" } + } + } + } + + await staleHistoryGate.release() + try await waitUntil("stale load history resumes") { + await staleHistoryReleasedCount.current() == 1 + } + + #expect(await MainActor.run { vm.sessionId } == "sess-current-refresh") + #expect(await MainActor.run { + !vm.messages.contains { message in + message.content.contains { $0.text == "stale load" } + } + }) + } + + @Test @MainActor func `manual refresh invalidates older same session event refresh`() async throws { + let staleRefreshGate = SessionSubscribeGate() + let mainHistoryCount = AsyncCounter() + let staleRefreshReleasedCount = AsyncCounter() + let (transport, vm) = await makeViewModel( + historyResponses: [ + historyPayload(sessionKey: "main", sessionId: "sess-main"), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-event-stale", + messages: [chatTextMessage(role: "assistant", text: "stale same-session event", timestamp: 1)]), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-manual-refresh", + messages: [chatTextMessage(role: "assistant", text: "current manual refresh", timestamp: 2)]), + ], + requestHistoryHook: { sessionKey in + guard sessionKey == "main" else { return } + let count = await mainHistoryCount.increment() + if count == 2 { + await staleRefreshGate.wait() + _ = await staleRefreshReleasedCount.increment() + } + }) + + try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main") + + transport.emit(.seqGap) + try await waitUntil("same-session event refresh is in flight") { + await mainHistoryCount.current() == 2 + } + + vm.refresh() + try await waitUntil("manual refresh wins") { + await MainActor.run { + vm.sessionId == "sess-main-manual-refresh" && + vm.messages.contains { message in + message.content.contains { $0.text == "current manual refresh" } + } + } + } + + await staleRefreshGate.release() + try await waitUntil("stale same-session event refresh resumes") { + await staleRefreshReleasedCount.current() == 1 + } + + #expect(await MainActor.run { vm.sessionId } == "sess-main-manual-refresh") + #expect(await MainActor.run { + !vm.messages.contains { message in + message.content.contains { $0.text == "stale same-session event" } + } + }) + } + + @Test @MainActor func `failed newer same session refresh does not drop older successful send refresh`() async throws { + let sendRefreshGate = SessionSubscribeGate() + let mainHistoryCount = AsyncCounter() + let now = Date().timeIntervalSince1970 * 1000 + let (transport, vm) = await makeViewModel( + historyResponses: [ + historyPayload(sessionKey: "main", sessionId: "sess-main"), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-send-refresh", + messages: [ + chatTextMessage(role: "user", text: "hello", timestamp: now), + chatTextMessage(role: "assistant", text: "reply from older success", timestamp: now + 1), + ]), + ], + requestHistoryHook: { sessionKey in + guard sessionKey == "main" else { return } + let count = await mainHistoryCount.increment() + if count == 2 { + await sendRefreshGate.wait() + } + if count == 3 { + throw NSError( + domain: "ChatViewModelTests", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "newer event refresh failed"]) + } + }) + + try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main") + + vm.input = "hello" + vm.send() + let runId = try await waitForLastSentRunId(transport) + try await waitUntil("post-send refresh is in flight") { + await mainHistoryCount.current() == 2 + } + + transport.emit( + .chat( + OpenClawChatEventPayload( + runId: runId, + sessionKey: "main", + state: "final", + message: nil, + errorMessage: nil))) + try await waitUntil("newer event refresh starts") { + await mainHistoryCount.current() == 3 + } + + await sendRefreshGate.release() + + try await waitUntil("older successful send refresh applies") { + await MainActor.run { + vm.sessionId == "sess-main-send-refresh" && + vm.messages.contains { message in + message.content.contains { $0.text == "reply from older success" } + } + } + } + } + + @Test @MainActor func `newer empty terminal refresh does not drop older assistant run refresh`() async throws { + let sendRefreshGate = SessionSubscribeGate() + let mainHistoryCount = AsyncCounter() + let now = Date().timeIntervalSince1970 * 1000 + let (transport, vm) = await makeViewModel( + historyResponses: [ + historyPayload(sessionKey: "main", sessionId: "sess-main"), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-send-refresh", + messages: [ + chatTextMessage(role: "user", text: "hello", timestamp: now), + chatTextMessage(role: "assistant", text: "reply from older success", timestamp: now + 1), + ]), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-terminal-empty-refresh", + messages: [chatTextMessage(role: "user", text: "hello", timestamp: now)]), + ], + requestHistoryHook: { sessionKey in + guard sessionKey == "main" else { return } + let count = await mainHistoryCount.increment() + if count == 2 { + await sendRefreshGate.wait() + } + }) + + try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main") + + vm.input = "hello" + vm.send() + let runId = try await waitForLastSentRunId(transport) + try await waitUntil("post-send refresh is in flight") { + await mainHistoryCount.current() == 2 + } + + transport.emit( + .chat( + OpenClawChatEventPayload( + runId: runId, + sessionKey: "main", + state: "final", + message: nil, + errorMessage: nil))) + try await waitUntil("newer empty terminal refresh applies") { + await MainActor.run { + vm.sessionId == "sess-main-terminal-empty-refresh" && + vm.pendingRunCount == 0 + } + } + + await sendRefreshGate.release() + + try await waitUntil("older successful send refresh applies assistant reply") { + await MainActor.run { + vm.sessionId == "sess-main-send-refresh" && + vm.pendingRunCount == 0 && + vm.messages.contains { message in + message.content.contains { $0.text == "reply from older success" } + } + } + } + } + + @Test @MainActor func `newer user only terminal refresh after final event message does not drop older assistant run refresh`() async throws { + let sendRefreshGate = SessionSubscribeGate() + let mainHistoryCount = AsyncCounter() + let now = Date().timeIntervalSince1970 * 1000 + let (transport, vm) = await makeViewModel( + historyResponses: [ + historyPayload(sessionKey: "main", sessionId: "sess-main"), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-send-refresh", + messages: [ + chatTextMessage(role: "user", text: "hello", timestamp: now), + chatTextMessage(role: "assistant", text: "reply from durable history", timestamp: now + 1), + ]), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-terminal-user-only-refresh", + messages: [chatTextMessage(role: "user", text: "hello", timestamp: now)]), + ], + requestHistoryHook: { sessionKey in + guard sessionKey == "main" else { return } + let count = await mainHistoryCount.increment() + if count == 2 { + await sendRefreshGate.wait() + } + }) + + try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main") + + vm.input = "hello" + vm.send() + let runId = try await waitForLastSentRunId(transport) + try await waitUntil("post-send refresh is in flight") { + await mainHistoryCount.current() == 2 + } + + transport.emit( + .chat( + OpenClawChatEventPayload( + runId: runId, + sessionKey: "main", + state: "final", + message: chatTextMessage( + role: "assistant", + text: "reply from final event", + timestamp: now + 0.5), + errorMessage: nil))) + try await waitUntil("newer user-only terminal refresh applies") { + await MainActor.run { + vm.sessionId == "sess-main-terminal-user-only-refresh" && + !vm.messages.contains { message in + message.content.contains { $0.text == "reply from final event" } + } + } + } + + await sendRefreshGate.release() + + try await waitUntil("older successful send refresh applies durable assistant reply") { + await MainActor.run { + vm.sessionId == "sess-main-send-refresh" && + vm.pendingRunCount == 0 && + vm.messages.contains { message in + message.content.contains { $0.text == "reply from durable history" } + } + } + } + } + + @Test @MainActor func `manual refresh user only history does not drop older assistant run refresh`() async throws { + let sendRefreshGate = SessionSubscribeGate() + let mainHistoryCount = AsyncCounter() + let now = Date().timeIntervalSince1970 * 1000 + let (_, vm) = await makeViewModel( + historyResponses: [ + historyPayload(sessionKey: "main", sessionId: "sess-main"), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-send-refresh", + messages: [ + chatTextMessage(role: "user", text: "hello", timestamp: now), + chatTextMessage(role: "assistant", text: "reply from older success", timestamp: now + 1), + ]), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-manual-user-only-refresh", + messages: [chatTextMessage(role: "user", text: "hello", timestamp: now)]), + ], + requestHistoryHook: { sessionKey in + guard sessionKey == "main" else { return } + let count = await mainHistoryCount.increment() + if count == 2 { + await sendRefreshGate.wait() + } + }) + + try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main") + + vm.input = "hello" + vm.send() + try await waitUntil("post-send refresh is in flight") { + await mainHistoryCount.current() == 2 + } + + vm.refresh() + try await waitUntil("manual user-only refresh applies") { + await MainActor.run { + vm.sessionId == "sess-main-manual-user-only-refresh" && + vm.pendingRunCount == 0 + } + } + + await sendRefreshGate.release() + + try await waitUntil("older successful send refresh applies after manual refresh") { + await MainActor.run { + vm.sessionId == "sess-main-send-refresh" && + vm.messages.contains { message in + message.content.contains { $0.text == "reply from older success" } + } + } + } + } + + @Test @MainActor func `manual refresh older complete history does not drop pending user assistant run refresh`() async throws { + let sendRefreshGate = SessionSubscribeGate() + let mainHistoryCount = AsyncCounter() + let now = Date().timeIntervalSince1970 * 1000 + let olderCompleteMessages = [ + chatTextMessage(role: "user", text: "older question", timestamp: now - 2), + chatTextMessage(role: "assistant", text: "older answer", timestamp: now - 1), + ] + let (_, vm) = await makeViewModel( + historyResponses: [ + historyPayload( + sessionKey: "main", + sessionId: "sess-main", + messages: olderCompleteMessages), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-send-refresh", + messages: olderCompleteMessages + [ + chatTextMessage(role: "user", text: "hello", timestamp: now), + chatTextMessage(role: "assistant", text: "reply from pending turn", timestamp: now + 1), + ]), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-manual-older-complete-refresh", + messages: olderCompleteMessages), + ], + requestHistoryHook: { sessionKey in + guard sessionKey == "main" else { return } + let count = await mainHistoryCount.increment() + if count == 2 { + await sendRefreshGate.wait() + } + }) + + try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main") + + vm.input = "hello" + vm.send() + try await waitUntil("post-send refresh is in flight") { + await mainHistoryCount.current() == 2 + } + + vm.refresh() + try await waitUntil("manual older complete refresh applies") { + await MainActor.run { + vm.sessionId == "sess-main-manual-older-complete-refresh" && + vm.messages.contains { message in + message.content.contains { $0.text == "older answer" } + } && + !vm.messages.contains { message in + message.content.contains { $0.text == "reply from pending turn" } + } + } + } + + await sendRefreshGate.release() + + try await waitUntil("older successful send refresh applies pending turn answer") { + await MainActor.run { + vm.sessionId == "sess-main-send-refresh" && + vm.messages.contains { message in + message.content.contains { $0.text == "reply from pending turn" } + } + } + } + } + + @Test @MainActor func `manual stale complete refresh after final event does not drop durable reply refresh`() async throws { + let sendRefreshGate = SessionSubscribeGate() + let eventRefreshGate = SessionSubscribeGate() + let mainHistoryCount = AsyncCounter() + let now = Date().timeIntervalSince1970 * 1000 + let olderCompleteMessages = [ + chatTextMessage(role: "user", text: "older question", timestamp: now - 2), + chatTextMessage(role: "assistant", text: "older answer", timestamp: now - 1), + ] + let (transport, vm) = await makeViewModel( + historyResponses: [ + historyPayload( + sessionKey: "main", + sessionId: "sess-main", + messages: olderCompleteMessages), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-send-refresh", + messages: olderCompleteMessages + [ + chatTextMessage(role: "user", text: "hello", timestamp: now), + chatTextMessage(role: "assistant", text: "durable reply", timestamp: now + 1), + ]), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-event-stale-complete-refresh", + messages: olderCompleteMessages), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-manual-stale-complete-refresh", + messages: olderCompleteMessages), + ], + requestHistoryHook: { sessionKey in + guard sessionKey == "main" else { return } + let count = await mainHistoryCount.increment() + if count == 2 { + await sendRefreshGate.wait() + } + if count == 3 { + await eventRefreshGate.wait() + } + }) + + try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main") + + vm.input = "hello" + vm.send() + let runId = try await waitForLastSentRunId(transport) + try await waitUntil("post-send refresh is in flight") { + await mainHistoryCount.current() == 2 + } + + transport.emit( + .chat( + OpenClawChatEventPayload( + runId: runId, + sessionKey: "main", + state: "final", + message: chatTextMessage(role: "assistant", text: "local final reply", timestamp: now + 0.5), + errorMessage: nil))) + try await waitUntil("local final event reply is visible") { + await MainActor.run { + vm.messages.contains { message in + message.content.contains { $0.text == "local final reply" } + } + } + } + + vm.refresh() + try await waitUntil("manual stale complete refresh applies without durable reply") { + let historyCount = await mainHistoryCount.current() + let stateMatches = await MainActor.run { + vm.sessionId == "sess-main-manual-stale-complete-refresh" && + !vm.messages.contains { message in + message.content.contains { $0.text == "durable reply" } + } + } + return historyCount == 4 && stateMatches + } + + await eventRefreshGate.release() + try await waitUntil("event stale complete refresh resumes") { + await MainActor.run { + vm.sessionId == "sess-main-event-stale-complete-refresh" + } + } + + await sendRefreshGate.release() + + try await waitUntil("older durable send refresh applies after manual stale refresh") { + await MainActor.run { + vm.sessionId == "sess-main-send-refresh" && + vm.messages.contains { message in + message.content.contains { $0.text == "durable reply" } + } + } + } + } + + @Test @MainActor func `bootstrap history does not overwrite newer same session refresh`() async throws { + let bootstrapHistoryGate = SessionSubscribeGate() + let mainHistoryCount = AsyncCounter() + let bootstrapHistoryReleasedCount = AsyncCounter() + let sessions = OpenClawChatSessionsListResponse( + ts: Date().timeIntervalSince1970 * 1000, + path: nil, + count: 1, + defaults: nil, + sessions: [sessionEntry(key: "main", updatedAt: Date().timeIntervalSince1970 * 1000)]) + let (transport, vm) = await makeViewModel( + historyResponses: [ + historyPayload( + sessionKey: "main", + sessionId: "sess-main-bootstrap-stale", + messages: [chatTextMessage(role: "assistant", text: "stale bootstrap", timestamp: 1)]), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-event-newer", + messages: [chatTextMessage(role: "assistant", text: "newer event refresh", timestamp: 2)]), + ], + sessionsResponses: [sessions], + modelResponses: [[modelChoice(id: "glm-5.1", name: "GLM 5.1")]], + requestHistoryHook: { sessionKey in + guard sessionKey == "main" else { return } + let count = await mainHistoryCount.increment() + if count == 1 { + await bootstrapHistoryGate.wait() + _ = await bootstrapHistoryReleasedCount.increment() + } + }) + + vm.load() + try await waitUntil("bootstrap history is in flight") { + await mainHistoryCount.current() == 1 + } + + transport.emit(.seqGap) + try await waitUntil("newer same-session refresh applies") { + await MainActor.run { + vm.sessionId == "sess-main-event-newer" && + vm.messages.contains { message in + message.content.contains { $0.text == "newer event refresh" } + } + } + } + + await bootstrapHistoryGate.release() + try await waitUntil("bootstrap history resumes") { + await bootstrapHistoryReleasedCount.current() == 1 + } + + #expect(await MainActor.run { vm.sessionId } == "sess-main-event-newer") + #expect(await MainActor.run { + !vm.messages.contains { message in + message.content.contains { $0.text == "stale bootstrap" } + } + }) + try await waitUntil("bootstrap metadata still loads") { + await MainActor.run { + vm.healthOK && + vm.sessions.contains { $0.key == "main" } && + vm.modelChoices.contains { $0.modelID == "glm-5.1" } + } + } + } + + @Test @MainActor func `stale fallback refresh keeps retrying while run remains pending`() async throws { + let staleFallbackGate = SessionSubscribeGate() + let mainHistoryCount = AsyncCounter() + let staleFallbackReleasedCount = AsyncCounter() + let now = (Date().timeIntervalSince1970 * 1000) + 10000 + let (transport, vm) = await makeViewModel( + historyResponses: [ + historyPayload(sessionKey: "main", sessionId: "sess-main"), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-send-refresh", + messages: [chatTextMessage(role: "user", text: "hello", timestamp: now)]), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-stale-fallback", + messages: [chatTextMessage(role: "user", text: "hello", timestamp: now)]), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-newer-empty-refresh", + messages: [chatTextMessage(role: "user", text: "hello", timestamp: now)]), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-next-fallback", + messages: [ + chatTextMessage(role: "user", text: "hello", timestamp: now), + chatTextMessage(role: "assistant", text: "reply from later fallback", timestamp: now + 1), + ]), + ], + requestHistoryHook: { sessionKey in + guard sessionKey == "main" else { return } + let count = await mainHistoryCount.increment() + if count == 3 { + await staleFallbackGate.wait() + _ = await staleFallbackReleasedCount.increment() + } + }) + + try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main") + + vm.input = "hello" + vm.send() + _ = try await waitForLastSentRunId(transport) + try await waitUntil("first fallback refresh is in flight") { + await mainHistoryCount.current() == 3 + } + + emitExternalFinal(transport: transport, runId: "external-run", sessionKey: "main") + try await waitUntil("newer empty refresh applies") { + await MainActor.run { vm.sessionId == "sess-main-newer-empty-refresh" } + } + + await staleFallbackGate.release() + try await waitUntil("stale fallback resumes") { + await staleFallbackReleasedCount.current() == 1 + } + + try await waitUntil("later fallback still runs", timeoutSeconds: 7.0) { + await mainHistoryCount.current() >= 5 + } + try await waitUntil("later fallback applies assistant reply") { + await MainActor.run { + vm.pendingRunCount == 0 && + vm.messages.contains { message in + message.content.contains { $0.text == "reply from later fallback" } + } + } + } + } + + @Test @MainActor func `stale bootstrap history does not overwrite latest session`() async throws { + let staleHistoryGate = SessionSubscribeGate() + let staleHistoryReleasedCount = AsyncCounter() + let (transport, vm) = await makeViewModel( + historyResponses: [ + historyPayload(sessionKey: "main", sessionId: "sess-main"), + historyPayload( + sessionKey: "other", + sessionId: "sess-other-stale", + messages: [chatTextMessage(role: "assistant", text: "stale other", timestamp: 1)]), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-current", + messages: [chatTextMessage(role: "assistant", text: "current main", timestamp: 2)]), + ], + requestHistoryHook: { sessionKey in + if sessionKey == "other" { + await staleHistoryGate.wait() + _ = await staleHistoryReleasedCount.increment() + } + }) + + try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main") + + vm.syncSession(to: "other") + try await waitUntil("other session subscribe starts") { + await transport.activeSessionKeys().last == "other" + } + + vm.syncSession(to: "main") + try await waitUntil("main session wins") { + await MainActor.run { + vm.sessionKey == "main" && + vm.sessionId == "sess-main-current" && + vm.messages.contains { message in + message.content.contains { $0.text == "current main" } + } + } + } + + await staleHistoryGate.release() + try await waitUntil("stale other history resumes") { + await staleHistoryReleasedCount.current() == 1 + } + + #expect(await MainActor.run { vm.sessionId } == "sess-main-current") + #expect(await MainActor.run { + !vm.messages.contains { message in + message.content.contains { $0.text == "stale other" } + } + }) + } + + @Test @MainActor func `session switch clears old latest user before new session refreshes`() async throws { + let staleBootstrapGate = SessionSubscribeGate() + let otherHistoryCount = AsyncCounter() + let staleBootstrapReleasedCount = AsyncCounter() + let (transport, vm) = await makeViewModel( + historyResponses: [ + historyPayload( + sessionKey: "main", + sessionId: "sess-main", + messages: [chatTextMessage(role: "user", text: "main pending question", timestamp: 1)]), + historyPayload( + sessionKey: "other", + sessionId: "sess-other-bootstrap-stale", + messages: [chatTextMessage(role: "assistant", text: "stale other bootstrap", timestamp: 2)]), + historyPayload( + sessionKey: "other", + sessionId: "sess-other-newer-refresh", + messages: [chatTextMessage(role: "assistant", text: "newer other refresh", timestamp: 3)]), + ], + requestHistoryHook: { sessionKey in + guard sessionKey == "other" else { return } + let count = await otherHistoryCount.increment() + if count == 1 { + await staleBootstrapGate.wait() + _ = await staleBootstrapReleasedCount.increment() + } + }) + + try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main") + + vm.syncSession(to: "other") + try await waitUntil("other bootstrap history is in flight") { + await otherHistoryCount.current() == 1 + } + #expect(await MainActor.run { vm.messages.isEmpty }) + + transport.emit(.seqGap) + try await waitUntil("newer other refresh applies") { + await MainActor.run { + vm.sessionKey == "other" && + vm.sessionId == "sess-other-newer-refresh" && + vm.messages.contains { message in + message.content.contains { $0.text == "newer other refresh" } + } + } + } + + await staleBootstrapGate.release() + try await waitUntil("stale other bootstrap resumes") { + await staleBootstrapReleasedCount.current() == 1 + } + + #expect(await MainActor.run { vm.sessionId } == "sess-other-newer-refresh") + #expect(await MainActor.run { + !vm.messages.contains { message in + message.content.contains { $0.text == "stale other bootstrap" } + } + }) + } + + @Test @MainActor func `stale seq gap refresh does not overwrite latest session`() async throws { + let staleRefreshGate = SessionSubscribeGate() + let mainHistoryCount = AsyncCounter() + let staleRefreshReleasedCount = AsyncCounter() + let (transport, vm) = await makeViewModel( + historyResponses: [ + historyPayload(sessionKey: "main", sessionId: "sess-main"), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-gap-stale", + messages: [chatTextMessage(role: "assistant", text: "stale gap", timestamp: 1)]), + historyPayload( + sessionKey: "other", + sessionId: "sess-other-current", + messages: [chatTextMessage(role: "assistant", text: "current other", timestamp: 2)]), + ], + requestHistoryHook: { sessionKey in + guard sessionKey == "main" else { return } + let count = await mainHistoryCount.increment() + if count == 2 { + await staleRefreshGate.wait() + _ = await staleRefreshReleasedCount.increment() + } + }) + + try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main") + + transport.emit(.seqGap) + try await waitUntil("seq gap refresh is in flight") { + await mainHistoryCount.current() == 2 + } + + vm.syncSession(to: "other") + try await waitUntil("other session bootstrap wins") { + await MainActor.run { + vm.sessionKey == "other" && + vm.sessionId == "sess-other-current" && + vm.messages.contains { message in + message.content.contains { $0.text == "current other" } + } + } + } + + await staleRefreshGate.release() + try await waitUntil("stale seq gap refresh resumes") { + await staleRefreshReleasedCount.current() == 1 + } + + #expect(await MainActor.run { vm.sessionId } == "sess-other-current") + #expect(await MainActor.run { + !vm.messages.contains { message in + message.content.contains { $0.text == "stale gap" } + } + }) + } + + @Test @MainActor func `send waiting for model patch does not send after session switch`() async throws { + let modelPatchGate = SessionSubscribeGate() + let modelPatchReleasedCount = AsyncCounter() + let models = [modelChoice(id: "gpt-5.4", name: "GPT-5.4", provider: "openai")] + let (transport, vm) = await makeViewModel( + historyResponses: [ + historyPayload(sessionKey: "main", sessionId: "sess-main"), + historyPayload(sessionKey: "other", sessionId: "sess-other"), + ], + modelResponses: [models, models], + setSessionModelHook: { _ in + await modelPatchGate.wait() + _ = await modelPatchReleasedCount.increment() + }) + + try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main") + + vm.selectModel("openai/gpt-5.4") + try await waitUntil("model patch is in flight") { + await transport.patchedModels() == ["openai/gpt-5.4"] + } + + vm.input = "hello before switch" + vm.send() + try await waitUntil("send is waiting for model patch") { + await MainActor.run { vm.pendingRunCount == 1 } + } + + vm.syncSession(to: "other") + try await waitUntil("session switch clears pending send") { + await MainActor.run { + vm.sessionKey == "other" && + vm.sessionId == "sess-other" && + vm.pendingRunCount == 0 + } + } + + await modelPatchGate.release() + try await waitUntil("model patch resumes") { + await modelPatchReleasedCount.current() == 1 + } + try await Task.sleep(for: .milliseconds(100)) + + #expect(await transport.sentRunIds().isEmpty) + } + + @Test @MainActor func `stale sync bootstrap restores current active session subscription`() async throws { let staleSubscribeGate = SessionSubscribeGate() let (transport, vm) = await makeViewModel( historyResponses: [ @@ -2271,11 +3270,47 @@ extension TestChatTransportState { await staleSubscribeGate.release() try await waitUntil("current session resubscribed after stale subscribe") { - Array(await transport.activeSessionKeys().suffix(3)) == ["other", "main", "main"] + await Array(transport.activeSessionKeys().suffix(3)) == ["other", "main", "main"] } } - @Test @MainActor func staleSyncRepairReassertsLatestActiveSessionSubscription() async throws { + @Test @MainActor func `stale subscribe failure reasserts current active session subscription`() async throws { + let staleSubscribeGate = SessionSubscribeGate() + let (transport, vm) = await makeViewModel( + historyResponses: [ + historyPayload(sessionKey: "main", sessionId: "sess-main"), + historyPayload(sessionKey: "main", sessionId: "sess-main"), + ], + setActiveSessionHook: { sessionKey in + if sessionKey == "other" { + await staleSubscribeGate.wait() + throw NSError( + domain: "TestChatTransport", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "stale subscribe failed after side effect"]) + } + }) + + try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main") + + vm.syncSession(to: "other") + try await waitUntil("stale subscribe is in flight") { + await transport.activeSessionKeys().last == "other" + } + + vm.syncSession(to: "main") + try await waitUntil("current session subscribed") { + await Array(transport.activeSessionKeys().suffix(2)) == ["other", "main"] + } + + await staleSubscribeGate.release() + + try await waitUntil("current session resubscribed after stale subscribe failure") { + await Array(transport.activeSessionKeys().suffix(3)) == ["other", "main", "main"] + } + } + + @Test @MainActor func `stale sync repair reasserts latest active session subscription`() async throws { let staleSubscribeGate = SessionSubscribeGate() let staleRepairGate = SessionSubscribeGate() let mainSubscribeCount = AsyncCounter() @@ -2306,12 +3341,12 @@ extension TestChatTransportState { vm.syncSession(to: "main") try await waitUntil("main session subscribed") { - Array(await transport.activeSessionKeys().suffix(2)) == ["other", "main"] + await Array(transport.activeSessionKeys().suffix(2)) == ["other", "main"] } await staleSubscribeGate.release() try await waitUntil("stale repair is in flight") { - Array(await transport.activeSessionKeys().suffix(3)) == ["other", "main", "main"] + await Array(transport.activeSessionKeys().suffix(3)) == ["other", "main", "main"] } vm.syncSession(to: "final") @@ -2324,11 +3359,11 @@ extension TestChatTransportState { await staleRepairGate.release() try await waitUntil("newest session resubscribed after stale repair") { - Array(await transport.activeSessionKeys().suffix(3)) == ["main", "final", "final"] + await Array(transport.activeSessionKeys().suffix(3)) == ["main", "final", "final"] } } - @Test func switchingSessionsIgnoresLateModelPatchCompletionFromPreviousSession() async throws { + @Test func `switching sessions ignores late model patch completion from previous session`() async throws { let now = Date().timeIntervalSince1970 * 1000 let sessions = OpenClawChatSessionsListResponse( ts: now, @@ -2373,7 +3408,7 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.sessions.first(where: { $0.key == "other" })?.model } == nil) } - @Test func lateModelCompletionDoesNotReplayCurrentSessionSelectionIntoPreviousSession() async throws { + @Test func `late model completion does not replay current session selection into previous session`() async throws { let now = Date().timeIntervalSince1970 * 1000 let initialSessions = OpenClawChatSessionsListResponse( ts: now, @@ -2445,7 +3480,7 @@ extension TestChatTransportState { #expect(await transport.patchedModels() == ["openai/gpt-5.4", "openai/gpt-5.4-pro"]) } - @Test func explicitThinkingLevelWinsOverHistoryAndPersistsChanges() async throws { + @Test func `explicit thinking level wins over history and persists changes`() async throws { let history = OpenClawChatHistoryPayload( sessionKey: "main", sessionId: "sess-main", @@ -2474,7 +3509,7 @@ extension TestChatTransportState { #expect(await MainActor.run { callbackState.values } == ["medium"]) } - @Test func serverProvidedThinkingLevelsOutsideMenuArePreservedForSend() async throws { + @Test func `server provided thinking levels outside menu are preserved for send`() async throws { let history = OpenClawChatHistoryPayload( sessionKey: "main", sessionId: "sess-main", @@ -2492,7 +3527,7 @@ extension TestChatTransportState { } } - @Test func decodesGatewayThinkingMetadataFromSessionList() throws { + @Test func `decodes gateway thinking metadata from session list`() throws { let json = """ { "defaults": { @@ -2536,7 +3571,7 @@ extension TestChatTransportState { #expect(decoded.sessions.first?.thinkingDefault == "max") } - @Test func sessionThinkingLevelsDrivePickerOptions() async throws { + @Test func `session thinking levels drive picker options`() async throws { let history = OpenClawChatHistoryPayload( sessionKey: "main", sessionId: "sess-main", @@ -2599,7 +3634,7 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.thinkingLevelOptions.map(\.label) } == ["off", "adaptive", "maximum"]) } - @Test func thinkingOptionsFallbackAndCurrentUnsupportedLevelStayVisible() async throws { + @Test func `thinking options fallback and current unsupported level stay visible`() async throws { let history = OpenClawChatHistoryPayload( sessionKey: "main", sessionId: "sess-main", @@ -2647,7 +3682,7 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.thinkingLevelOptions.map(\.label) } == ["off", "max", "xhigh"]) } - @Test func matchingDefaultThinkingLevelsBeatLegacyRowThinkingOptions() async throws { + @Test func `matching default thinking levels beat legacy row thinking options`() async throws { let history = OpenClawChatHistoryPayload( sessionKey: "main", sessionId: "sess-main", @@ -2703,7 +3738,7 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.thinkingLevelOptions.map(\.id) } == ["off", "adaptive", "max"]) } - @Test func defaultThinkingLevelsDoNotLeakToDifferentSessionModel() async throws { + @Test func `default thinking levels do not leak to different session model`() async throws { let history = OpenClawChatHistoryPayload( sessionKey: "main", sessionId: "sess-main", @@ -2758,7 +3793,7 @@ extension TestChatTransportState { ["off", "minimal", "low", "medium", "high", "max"]) } - @Test func staleThinkingPatchCompletionReappliesLatestSelection() async throws { + @Test func `stale thinking patch completion reapplies latest selection`() async throws { let history = OpenClawChatHistoryPayload( sessionKey: "main", sessionId: "sess-main", @@ -2788,7 +3823,7 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.thinkingLevel } == "high") } - @Test func clearsStreamingOnExternalErrorEvent() async throws { + @Test func `clears streaming on external error event`() async throws { let sessionId = "sess-main" let history = historyPayload(sessionId: sessionId) let (transport, vm) = await makeViewModel(historyResponses: [history, history]) @@ -2812,7 +3847,7 @@ extension TestChatTransportState { try await waitUntil("streaming cleared") { await MainActor.run { vm.streamingAssistantText == nil } } } - @Test func stripsInboundMetadataFromHistoryMessages() async throws { + @Test func `strips inbound metadata from history messages`() async throws { let history = OpenClawChatHistoryPayload( sessionKey: "main", sessionId: "sess-main", @@ -2841,7 +3876,7 @@ extension TestChatTransportState { #expect(sanitized == "Hello?") } - @Test func abortRequestsDoNotClearPendingUntilAbortedEvent() async throws { + @Test func `abort requests do not clear pending until aborted event`() async throws { let sessionId = "sess-main" let history = historyPayload(sessionId: sessionId) let (transport, vm) = await makeViewModel(historyResponses: [history, history])