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