mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 16:44:00 +00:00
Adds an additive worktree flag to sessions.create so any new chat can run in an isolated managed worktree of the agent's git workspace, with the branch checked out and .worktreeinclude provisioning applied. The session's spawnedCwd points at the matching subdirectory inside the worktree so chat runs, CLI, and the file browser execute there. agents.list gains workspaceGit (workspace or an ancestor is a git checkout) to gate the affordance; web sidebar, iOS, and Android expose a New-Chat-in-worktree action. Uses the method's operator.write scope, but the .openclaw/worktree-setup.sh step runs only for operator.admin callers since it executes repo code. Deleting the session, or leaving via a plain New Chat, clears the cwd and lossless-removes the worktree; idle GC treats recent session activity as worktree activity so an active session's checkout is never swept. Live-verified end-to-end on a real gateway; follow-up to #100535 (issue #100534).
2410 lines
101 KiB
Swift
2410 lines
101 KiB
Swift
import Foundation
|
|
import Observation
|
|
import OpenClawKit
|
|
import OSLog
|
|
|
|
private let chatUILogger = Logger(subsystem: "ai.openclaw", category: "OpenClawChatUI")
|
|
|
|
@MainActor
|
|
@Observable
|
|
// swiftlint:disable:next type_body_length
|
|
public final class OpenClawChatViewModel {
|
|
public static let defaultModelSelectionID = "__default__"
|
|
static let maxAttachmentBytes = 5_000_000
|
|
|
|
public internal(set) var messages: [OpenClawChatMessage] = []
|
|
|
|
public var input: String = ""
|
|
public private(set) var thinkingLevel: String
|
|
/// Setter is module-internal for the thinking-level extension only.
|
|
public internal(set) var thinkingLevelOptions: [OpenClawChatThinkingLevelOption]
|
|
public private(set) var modelSelectionID: String = "__default__"
|
|
public private(set) var modelChoices: [OpenClawChatModelChoice] = []
|
|
public private(set) var slashCommands: [OpenClawChatCommandChoice] = []
|
|
public private(set) var isLoadingSlashCommands = false
|
|
public private(set) var slashCommandsErrorText: String?
|
|
public private(set) var hasLoadedSlashCommands = false
|
|
@ObservationIgnored
|
|
private var slashFilterCache: SlashFilterCache?
|
|
|
|
private struct SlashFilterCache {
|
|
let query: String
|
|
let filter: OpenClawChatCommandFilter
|
|
let result: [OpenClawChatCommandChoice]
|
|
}
|
|
|
|
public private(set) var isLoading = false
|
|
public private(set) var isSending = false
|
|
public private(set) var isAborting = false
|
|
public var errorText: String?
|
|
public var attachments: [OpenClawPendingAttachment] = []
|
|
/// Setter is module-internal for the health/outbox extension only.
|
|
public internal(set) var healthOK: Bool = false
|
|
public private(set) var pendingRunCount: Int = 0
|
|
|
|
public private(set) var sessionKey: String
|
|
public private(set) var sessionId: String?
|
|
public private(set) var streamingAssistantText: String?
|
|
|
|
public private(set) var pendingToolCalls: [OpenClawChatPendingToolCall] = []
|
|
|
|
private(set) var timelineRevision: UInt64 = 0
|
|
/// Setter is module-internal for the transcript-cache extension only.
|
|
public internal(set) var sessions: [OpenClawChatSessionEntry] = []
|
|
/// True while the visible transcript came from the offline cache and no
|
|
/// live history response has replaced it yet (possibly stale).
|
|
public internal(set) var isShowingCachedTranscript = false
|
|
/// Guard the cache pre-paint: once a live response applied (even an empty
|
|
/// one), a slow cache read must never paint stale rows over it.
|
|
var hasAppliedLiveHistory = false
|
|
var hasAppliedLiveSessions = false
|
|
/// Internal for the outbox extension's flush path only.
|
|
let transport: any OpenClawChatTransport
|
|
let haptics: OpenClawChatHaptics
|
|
let transcriptCache: (any OpenClawChatTranscriptCache)?
|
|
let outbox: (any OpenClawChatCommandOutbox)?
|
|
/// Per-message outbox display state; rows without an entry are normal
|
|
/// transcript rows. Observable so bubbles update when flush progresses.
|
|
public internal(set) var outboxStatesByMessageID: [UUID: OpenClawChatOutboxMessageState] = [:]
|
|
@ObservationIgnored
|
|
var outboxCommandIDsByMessageID: [UUID: String] = [:]
|
|
@ObservationIgnored
|
|
var outboxMessageIDsByCommandID: [String: UUID] = [:]
|
|
@ObservationIgnored
|
|
var isFlushingOutbox = false
|
|
@ObservationIgnored
|
|
var isOutboxFlushRequestedWhileActive = false
|
|
@ObservationIgnored
|
|
var hasRecoveredInterruptedOutboxSends = false
|
|
/// Tombstones set synchronously on user delete so an active flush pass
|
|
/// never sends a command whose bubble the user just removed.
|
|
@ObservationIgnored
|
|
var deletedOutboxCommandIDs: Set<String> = []
|
|
/// Backoff between failed flush attempts; internal so tests can shorten it.
|
|
@ObservationIgnored
|
|
var outboxRetryDelaysMs: [UInt64] = [2000, 8000]
|
|
/// Consecutive transport-level flush failures. Paces retries up the
|
|
/// delay ladder without touching the durable per-command retryCount
|
|
/// (reserved for gateway verdicts); past the ladder, health drops and
|
|
/// the reconnect machinery owns pacing.
|
|
@ObservationIgnored
|
|
var outboxTransportFailureStreak = 0
|
|
/// User idempotency keys of turns just flushed from the outbox whose
|
|
/// durable outbox row is already deleted but which no history snapshot
|
|
/// has confirmed yet. Reconciliation must not evict them: the gateway
|
|
/// history can lag the ack, and eviction here would drop the turn from
|
|
/// both the screen and the write-through cache. Drained when a snapshot
|
|
/// contains the key; bounded because every flush drains or session
|
|
/// switches clear it.
|
|
@ObservationIgnored
|
|
var recentlySentOutboxUserKeys: Set<String> = []
|
|
/// False until restoreOutboxMessages has adopted durable rows for the
|
|
/// visible session. Until then the in-memory outbox state is blind to
|
|
/// rows persisted by an earlier process, so the FIFO send gate must
|
|
/// assume a backlog exists.
|
|
@ObservationIgnored
|
|
var hasRestoredOutboxMessages = false
|
|
@ObservationIgnored
|
|
nonisolated(unsafe) var outboxRetryTask: Task<Void, Never>?
|
|
/// A command becomes terminally 'failed' after this many send attempts.
|
|
nonisolated static let maxOutboxSendAttempts = 3
|
|
@ObservationIgnored
|
|
var pendingCacheWriteTask: Task<Void, Never>?
|
|
private(set) var activeAgentId: String?
|
|
var sessionDefaults: OpenClawChatSessionsDefaults?
|
|
private let prefersExplicitThinkingLevel: Bool
|
|
private let onSessionChanged: (@MainActor (String) -> Void)?
|
|
private let onThinkingLevelChanged: (@MainActor @Sendable (String) -> Void)?
|
|
private let diagnosticsLog: (@MainActor @Sendable (String) -> Void)?
|
|
|
|
@ObservationIgnored
|
|
private nonisolated(unsafe) var eventTask: Task<Void, Never>?
|
|
@ObservationIgnored
|
|
private nonisolated(unsafe) var bootstrapTask: Task<Void, Never>?
|
|
private var runOwnershipGeneration: UInt64 = 0
|
|
private var latestAppliedRunSnapshotRequestID: UInt64 = 0
|
|
private var isApplyingRunSnapshot = false
|
|
var pendingRuns = Set<String>() {
|
|
didSet {
|
|
if self.pendingRuns != oldValue, !self.isApplyingRunSnapshot {
|
|
self.runOwnershipGeneration &+= 1
|
|
}
|
|
let nextCount = self.pendingRuns.count
|
|
guard nextCount != self.pendingRunCount else { return }
|
|
self.pendingRunCount = nextCount
|
|
self.markTimelineChanged()
|
|
}
|
|
}
|
|
|
|
var pendingLocalUserEchoMessageIDsByRunID: [String: UUID] = [:]
|
|
// Final chat events and durable session-message rows arrive independently.
|
|
// Keep each provisional final scoped to the run's user turn so a later identical
|
|
// answer in the same session does not adopt or suppress the wrong row.
|
|
var runMessageScopesByRunID: [String: RunMessageScope] = [:]
|
|
var provisionalFinalMessagesByID: [UUID: ProvisionalFinalMessage] = [:]
|
|
private var sessionGeneration: UInt64 = 0
|
|
private var bootstrapGeneration: UInt64 = 0
|
|
// A newer same-session history request only invalidates older responses after it applies.
|
|
// Failed later refreshes must not drop the last successful pending-run history payload.
|
|
private var lastIssuedHistoryRequestID: UInt64 = 0
|
|
private var latestAppliedHistoryRequestID: UInt64 = 0
|
|
private var historyMutationGeneration: UInt64 = 0
|
|
|
|
@ObservationIgnored
|
|
private nonisolated(unsafe) var pendingRunTimeoutTasks: [String: Task<Void, Never>] = [:]
|
|
private var nextPendingRunTimeoutArmID: UInt64 = 0
|
|
private var pendingRunTimeoutArmIDs: [String: UInt64] = [:]
|
|
private let pendingRunTimeoutMs: UInt64 = 120_000
|
|
private static let postSendRefreshDelaysMs: [UInt64] = [
|
|
1500,
|
|
4000,
|
|
9000,
|
|
20000,
|
|
45000,
|
|
90000,
|
|
]
|
|
// Session switches can overlap in-flight picker patches, so stale completions
|
|
// must compare against the latest request and latest desired value for that session.
|
|
private var nextModelSelectionRequestID: UInt64 = 0
|
|
private var latestModelSelectionRequestIDsBySession: [String: UInt64] = [:]
|
|
private var latestModelSelectionIDsBySession: [String: String] = [:]
|
|
private var lastSuccessfulModelSelectionIDsBySession: [String: String] = [:]
|
|
private var inFlightModelPatchCountsBySession: [String: Int] = [:]
|
|
private var modelPatchWaitersBySession: [String: [CheckedContinuation<Void, Never>]] = [:]
|
|
private var nextThinkingSelectionRequestID: UInt64 = 0
|
|
private var latestThinkingSelectionRequestIDsBySession: [String: UInt64] = [:]
|
|
private var latestThinkingLevelsBySession: [String: String] = [:]
|
|
private var isCompacting = false
|
|
private var lastCompactAt: Date?
|
|
private let compactCooldown: TimeInterval = 60
|
|
|
|
private enum SessionSwitchIntent {
|
|
case userInitiated
|
|
case externalSync
|
|
}
|
|
|
|
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 pendingRunIDs: Set<String>
|
|
var visibleMessagesByID: [UUID: OpenClawChatMessage]
|
|
var historyMutationGeneration: UInt64
|
|
var runOwnershipGeneration: UInt64
|
|
var latestUserTurn: LatestUserTurn?
|
|
}
|
|
|
|
struct LatestUserTurn {
|
|
var idempotencyKey: String?
|
|
var refreshKey: String?
|
|
var occurrence: Int
|
|
var timestamp: Double?
|
|
}
|
|
|
|
struct RunMessageScope {
|
|
var session: SessionSnapshot
|
|
var latestUserTurn: LatestUserTurn?
|
|
}
|
|
|
|
struct ProvisionalFinalMessage {
|
|
var reconciliationKey: String
|
|
var runId: String?
|
|
var scope: RunMessageScope
|
|
}
|
|
|
|
private var pendingToolCallsById: [String: OpenClawChatPendingToolCall] = [:] {
|
|
didSet {
|
|
guard self.pendingToolCallsById != oldValue else { return }
|
|
self.pendingToolCalls = self.pendingToolCallsById.values
|
|
.sorted { ($0.startedAt ?? 0) < ($1.startedAt ?? 0) }
|
|
self.markTimelineChanged()
|
|
}
|
|
}
|
|
|
|
var lastHealthPollAt: Date?
|
|
|
|
public init(
|
|
sessionKey: String,
|
|
transport: any OpenClawChatTransport,
|
|
activeAgentId: String? = nil,
|
|
haptics: OpenClawChatHaptics = OpenClawChatHaptics(),
|
|
transcriptCache: (any OpenClawChatTranscriptCache)? = nil,
|
|
outbox: (any OpenClawChatCommandOutbox)? = nil,
|
|
initialThinkingLevel: String? = nil,
|
|
onSessionChanged: (@MainActor (String) -> Void)? = nil,
|
|
onThinkingLevelChanged: (@MainActor @Sendable (String) -> Void)? = nil,
|
|
diagnosticsLog: (@MainActor @Sendable (String) -> Void)? = nil)
|
|
{
|
|
self.sessionKey = sessionKey
|
|
self.transport = transport
|
|
self.haptics = haptics
|
|
self.transcriptCache = transcriptCache
|
|
let normalizedAgentId = activeAgentId?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
self.activeAgentId = normalizedAgentId?.isEmpty == false ? normalizedAgentId : nil
|
|
self.outbox = outbox
|
|
let normalizedThinkingLevel = Self.normalizedThinkingLevel(initialThinkingLevel)
|
|
let initialResolvedThinkingLevel = normalizedThinkingLevel ?? "off"
|
|
self.thinkingLevel = initialResolvedThinkingLevel
|
|
self.thinkingLevelOptions = Self.withCurrentThinkingOption(
|
|
Self.baseThinkingLevelOptions,
|
|
current: initialResolvedThinkingLevel)
|
|
self.prefersExplicitThinkingLevel = normalizedThinkingLevel != nil
|
|
self.onSessionChanged = onSessionChanged
|
|
self.onThinkingLevelChanged = onThinkingLevelChanged
|
|
self.diagnosticsLog = diagnosticsLog
|
|
|
|
let transport = self.transport
|
|
self.eventTask = Task { [weak self, transport] in
|
|
let stream = transport.events()
|
|
for await evt in stream {
|
|
if Task.isCancelled { return }
|
|
await MainActor.run { [weak self] in
|
|
self?.handleTransportEvent(evt)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
deinit {
|
|
self.eventTask?.cancel()
|
|
self.bootstrapTask?.cancel()
|
|
self.outboxRetryTask?.cancel()
|
|
for (_, task) in self.pendingRunTimeoutTasks {
|
|
task.cancel()
|
|
}
|
|
}
|
|
|
|
public func load() {
|
|
self.startBootstrap()
|
|
}
|
|
|
|
public func refresh() {
|
|
self.startBootstrap()
|
|
}
|
|
|
|
public func resumeFromForeground() {
|
|
Task { await self.refreshRunStateAfterForeground() }
|
|
}
|
|
|
|
public func send() {
|
|
self.logDiagnostic(
|
|
"chat.ui send invoked sessionKey=\(self.sessionKey) "
|
|
+ "inputLen=\(self.input.count) attachments=\(self.attachments.count) "
|
|
+ "pending=\(self.pendingRunCount) sending=\(self.isSending) "
|
|
+ "health=\(self.healthOK)")
|
|
Task { await self.performSend() }
|
|
}
|
|
|
|
public func abort() {
|
|
Task { await self.performAbort() }
|
|
}
|
|
|
|
public func refreshSessions(limit: Int? = nil) {
|
|
let context = self.currentSessionSnapshot()
|
|
Task { await self.fetchSessions(limit: limit, sessionSnapshot: context) }
|
|
}
|
|
|
|
public func startNewSession(worktree: Bool = false) async {
|
|
await self.performStartNewSession(worktree: worktree)
|
|
}
|
|
|
|
public func switchSession(to sessionKey: String) {
|
|
self.applySessionSwitch(to: sessionKey, intent: .userInitiated)
|
|
}
|
|
|
|
public func syncSession(to sessionKey: String) {
|
|
self.applySessionSwitch(to: sessionKey, intent: .externalSync)
|
|
}
|
|
|
|
public func syncActiveAgentId(_ agentId: String?) {
|
|
let normalized = agentId?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
let nextAgentId = normalized?.isEmpty == false ? normalized : nil
|
|
guard self.activeAgentId != nextAgentId else { return }
|
|
self.activeAgentId = nextAgentId
|
|
let normalizedSessionKey = self.sessionKey.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
let normalizedMainSessionKey = self.resolvedMainSessionKey
|
|
.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
guard normalizedSessionKey == "global" ||
|
|
normalizedSessionKey == "main" ||
|
|
normalizedSessionKey == normalizedMainSessionKey
|
|
else {
|
|
return
|
|
}
|
|
// Main aliases belong to the configured default agent. Changing that
|
|
// identity changes their transcript owner, so no old request or row may cross it.
|
|
self.advanceSessionGeneration()
|
|
self.clearSessionOwnedState()
|
|
self.startBootstrap()
|
|
}
|
|
|
|
public func selectThinkingLevel(_ level: String) {
|
|
Task { await self.performSelectThinkingLevel(level) }
|
|
}
|
|
|
|
public func selectModel(_ selectionID: String) {
|
|
Task { await self.performSelectModel(selectionID) }
|
|
}
|
|
|
|
public func loadSlashCommandsIfNeeded() {
|
|
guard self.transport.supportsSlashCommandCatalog else { return }
|
|
guard !self.hasLoadedSlashCommands, !self.isLoadingSlashCommands else { return }
|
|
Task { await self.loadSlashCommands(force: false) }
|
|
}
|
|
|
|
public func refreshSlashCommands() {
|
|
guard self.transport.supportsSlashCommandCatalog else { return }
|
|
Task { await self.loadSlashCommands(force: true) }
|
|
}
|
|
|
|
public func slashCommandMatches(
|
|
query: String,
|
|
filter: OpenClawChatCommandFilter) -> [OpenClawChatCommandChoice]
|
|
{
|
|
if let cache = self.slashFilterCache, cache.query == query, cache.filter == filter {
|
|
return cache.result
|
|
}
|
|
let result = Self.filteredSlashCommands(self.slashCommands, query: query, filter: filter)
|
|
self.slashFilterCache = SlashFilterCache(query: query, filter: filter, result: result)
|
|
return result
|
|
}
|
|
|
|
public func applySlashCommandSelection(_ command: OpenClawChatCommandChoice) {
|
|
let invocation = command.preferredInvocation.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !invocation.isEmpty else { return }
|
|
self.input = command.acceptsArgs ? "\(invocation) " : invocation
|
|
self.errorText = nil
|
|
}
|
|
|
|
var resolvedMainSessionKey: String {
|
|
let trimmed = self.sessionDefaults?.mainSessionKey?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return (trimmed?.isEmpty == false ? trimmed : nil) ?? "main"
|
|
}
|
|
|
|
public var showsModelPicker: Bool {
|
|
!self.modelChoices.isEmpty
|
|
}
|
|
|
|
public var defaultModelLabel: String {
|
|
guard let defaultModelID = normalizedModelSelectionID(sessionDefaults?.model) else {
|
|
return "Default"
|
|
}
|
|
return "Default: \(self.modelLabel(for: defaultModelID))"
|
|
}
|
|
|
|
static let baseThinkingLevelOptions: [OpenClawChatThinkingLevelOption] = [
|
|
OpenClawChatThinkingLevelOption(id: "off", label: "off"),
|
|
OpenClawChatThinkingLevelOption(id: "minimal", label: "minimal"),
|
|
OpenClawChatThinkingLevelOption(id: "low", label: "low"),
|
|
OpenClawChatThinkingLevelOption(id: "medium", label: "medium"),
|
|
OpenClawChatThinkingLevelOption(id: "high", label: "high"),
|
|
]
|
|
|
|
public func addAttachments(urls: [URL]) {
|
|
Task { await self.loadAttachments(urls: urls) }
|
|
}
|
|
|
|
public func addImageAttachment(data: Data, fileName: String, mimeType: String) {
|
|
Task { await self.addImageAttachment(url: nil, data: data, fileName: fileName, mimeType: mimeType) }
|
|
}
|
|
|
|
public func removeAttachment(_ id: OpenClawPendingAttachment.ID) {
|
|
self.attachments.removeAll { $0.id == id }
|
|
}
|
|
|
|
public var canSend: Bool {
|
|
!self.isSending && self.pendingRunCount == 0 && self.hasDraftToSend
|
|
}
|
|
|
|
public var hasDraftToSend: Bool {
|
|
let trimmed = self.input.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return !trimmed.isEmpty || !self.attachments.isEmpty
|
|
}
|
|
|
|
public var canSendDraft: Bool {
|
|
!self.isSending && self.hasDraftToSend
|
|
}
|
|
|
|
// MARK: - Internals
|
|
|
|
func markTimelineChanged() {
|
|
self.timelineRevision &+= 1
|
|
}
|
|
|
|
private func appendMessage(_ message: OpenClawChatMessage) {
|
|
self.messages.append(message)
|
|
self.markTimelineChanged()
|
|
}
|
|
|
|
private func removeMessage(id: UUID) {
|
|
let previousCount = self.messages.count
|
|
self.messages.removeAll { $0.id == id }
|
|
if self.messages.count != previousCount {
|
|
self.markTimelineChanged()
|
|
}
|
|
}
|
|
|
|
private func updateStreamingAssistantText(_ text: String?) {
|
|
guard self.streamingAssistantText != text else { return }
|
|
self.streamingAssistantText = text
|
|
self.markTimelineChanged()
|
|
}
|
|
|
|
private func logDiagnostic(_ message: String) {
|
|
self.diagnosticsLog?(message)
|
|
}
|
|
|
|
func currentSessionSnapshot() -> SessionSnapshot {
|
|
SessionSnapshot(key: self.sessionKey, generation: self.sessionGeneration)
|
|
}
|
|
|
|
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 invalidateRunSnapshots() {
|
|
self.runOwnershipGeneration &+= 1
|
|
}
|
|
|
|
private func invalidateHistorySnapshots() {
|
|
self.historyMutationGeneration &+= 1
|
|
}
|
|
|
|
private func beginHistoryRequest(
|
|
for sessionSnapshot: SessionSnapshot? = nil,
|
|
captureLatestUserTurn: Bool = true) -> HistoryRequest
|
|
{
|
|
self.lastIssuedHistoryRequestID &+= 1
|
|
return HistoryRequest(
|
|
id: self.lastIssuedHistoryRequestID,
|
|
session: sessionSnapshot ?? self.currentSessionSnapshot(),
|
|
pendingRunIDs: self.pendingRuns,
|
|
visibleMessagesByID: Dictionary(uniqueKeysWithValues: self.messages.map { ($0.id, $0) }),
|
|
historyMutationGeneration: self.historyMutationGeneration,
|
|
runOwnershipGeneration: self.runOwnershipGeneration,
|
|
latestUserTurn: captureLatestUserTurn ? Self.latestUserTurn(in: self.messages) : nil)
|
|
}
|
|
|
|
private func markHistoryRequestApplied(_ request: HistoryRequest) {
|
|
self.latestAppliedHistoryRequestID = max(self.latestAppliedHistoryRequestID, request.id)
|
|
}
|
|
|
|
@discardableResult
|
|
private func applyHistoryPayload(
|
|
_ payload: OpenClawChatHistoryPayload,
|
|
for request: HistoryRequest,
|
|
preservingOptimisticLocalMessages: Bool,
|
|
syncThinkingOptions: Bool = false) -> Bool
|
|
{
|
|
guard self.canApplyHistory(request) else { return false }
|
|
let incoming = self.adoptingProvisionalFinalMessageIDs(
|
|
in: Self.decodeMessages(payload.messages ?? []))
|
|
let unmatchedProvisionalFinalIDs = Set(self.provisionalFinalMessagesMissing(from: incoming).map(\.id))
|
|
var retainedMessageIDs = unmatchedProvisionalFinalIDs
|
|
if request.historyMutationGeneration != self.historyMutationGeneration {
|
|
for message in self.messages where request.visibleMessagesByID[message.id] != message {
|
|
let isMatchedProvisional = self.provisionalFinalMessagesByID[message.id] != nil &&
|
|
!unmatchedProvisionalFinalIDs.contains(message.id)
|
|
if !isMatchedProvisional {
|
|
retainedMessageIDs.insert(message.id)
|
|
}
|
|
}
|
|
}
|
|
var nextMessages = if preservingOptimisticLocalMessages {
|
|
Self.reconcileRunRefreshMessages(
|
|
previous: self.messages,
|
|
incoming: incoming,
|
|
pendingLocalUserEchoIDs: Set(self.pendingLocalUserEchoMessageIDsByRunID.values))
|
|
} else {
|
|
Self.reconcileMessageIDs(previous: self.messages, incoming: incoming)
|
|
}
|
|
// Ack-to-history window: turns just flushed from the outbox may not
|
|
// be in this snapshot yet. Their durable rows are gone, so keep the
|
|
// visible rows (appended: they are the newest turns) until a
|
|
// snapshot carries their idempotency key.
|
|
if !self.recentlySentOutboxUserKeys.isEmpty {
|
|
self.recentlySentOutboxUserKeys.subtract(incoming.compactMap(\.idempotencyKey))
|
|
let nextKeys = Set(nextMessages.compactMap(\.idempotencyKey))
|
|
let preserved = self.messages.filter { message in
|
|
guard let key = message.idempotencyKey else { return false }
|
|
return self.recentlySentOutboxUserKeys.contains(key) && !nextKeys.contains(key)
|
|
}
|
|
nextMessages.append(contentsOf: preserved)
|
|
}
|
|
let reconciledMessageIDs = Set(nextMessages.map(\.id))
|
|
nextMessages.append(contentsOf: self.messages.filter { message in
|
|
retainedMessageIDs.contains(message.id) && !reconciledMessageIDs.contains(message.id)
|
|
})
|
|
self.replaceMessages(Self.dedupeMessages(nextMessages))
|
|
self.prunePendingLocalUserEchoMessageIDs()
|
|
self.clearProvisionalFinalMarkersAdoptedByHistory(incoming)
|
|
self.pruneProvisionalFinalMessages()
|
|
self.pruneRunMessageScopes()
|
|
self.rescopeRunsAdoptedAfterHistoryRequest(request)
|
|
self.sessionId = payload.sessionId
|
|
self.applyInFlightRunSnapshot(payload.inFlightRun, for: request)
|
|
// Incomplete refreshes can arrive before durable assistant history.
|
|
// The latest visible user turn must survive answered before it can reject older replies.
|
|
let canInvalidateOlderHistory = if let latestUserTurn = request.latestUserTurn {
|
|
Self.hasAnsweredUser(latestUserTurn, in: self.messages)
|
|
} else {
|
|
!Self.hasUnansweredLatestUser(in: self.messages)
|
|
}
|
|
if canInvalidateOlderHistory {
|
|
self.markHistoryRequestApplied(request)
|
|
}
|
|
let appliedThinkingLevel = !self.prefersExplicitThinkingLevel
|
|
? Self.normalizedThinkingLevel(payload.thinkingLevel)
|
|
: nil
|
|
if let level = appliedThinkingLevel {
|
|
self.thinkingLevel = level
|
|
}
|
|
if syncThinkingOptions || appliedThinkingLevel != nil {
|
|
self.syncThinkingLevelOptions()
|
|
}
|
|
// Live history is the source of truth: it clears the cached marker and
|
|
// is written through so the next cold open pre-paints current rows.
|
|
self.hasAppliedLiveHistory = true
|
|
self.isShowingCachedTranscript = false
|
|
// An empty post-send refresh is incomplete by contract: reconciliation
|
|
// preserves the visible transcript, so preserve its last canonical cache too.
|
|
if !preservingOptimisticLocalMessages || !incoming.isEmpty {
|
|
// Persist the RECONCILED transcript, not raw incoming: a stale
|
|
// snapshot missing a just-acked turn must not overwrite the
|
|
// cache after the durable outbox row is already gone — the cache
|
|
// mirrors what the user sees.
|
|
self.persistTranscriptToCache(sessionKey: request.session.key, messages: nextMessages)
|
|
}
|
|
// Wholesale history replacement drops local-only queued bubbles;
|
|
// re-adopt or re-append them from the durable outbox.
|
|
self.restoreOutboxMessages(session: request.session)
|
|
return true
|
|
}
|
|
|
|
private func provisionalFinalMessagesMissing(
|
|
from incoming: [OpenClawChatMessage]) -> [OpenClawChatMessage]
|
|
{
|
|
let incomingRunIds = Set(incoming.compactMap { Self.normalizedIdempotencyKey($0.idempotencyKey) })
|
|
return self.messages.filter { message in
|
|
guard let provisional = provisionalFinalMessagesByID[message.id] else { return false }
|
|
if let runId = provisional.runId, incomingRunIds.contains(runId) {
|
|
return false
|
|
}
|
|
guard Self.containsUserTurn(provisional.scope.latestUserTurn, in: incoming) else {
|
|
return true
|
|
}
|
|
let searchRange = Self.messageRange(after: provisional.scope.latestUserTurn, in: incoming)
|
|
return !incoming[searchRange].contains { incomingMessage in
|
|
Self.finalMessageReconciliationKey(for: incomingMessage) == provisional.reconciliationKey
|
|
}
|
|
}
|
|
}
|
|
|
|
private func rescopeRunsAdoptedAfterHistoryRequest(_ request: HistoryRequest) {
|
|
for runId in self.pendingRuns {
|
|
let scope = self.runMessageScopesByRunID[runId]
|
|
if !request.pendingRunIDs.contains(runId) || scope?.latestUserTurn == nil {
|
|
self.runMessageScopesByRunID[runId] = self.currentRunMessageScope()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func applyInFlightRunSnapshot(
|
|
_ snapshot: OpenClawChatInFlightRun?,
|
|
for request: HistoryRequest)
|
|
{
|
|
guard request.runOwnershipGeneration == self.runOwnershipGeneration,
|
|
request.id >= self.latestAppliedRunSnapshotRequestID
|
|
else {
|
|
return
|
|
}
|
|
self.latestAppliedRunSnapshotRequestID = request.id
|
|
guard let snapshot,
|
|
let runId = Self.normalizedRunID(snapshot.runId)
|
|
else {
|
|
return
|
|
}
|
|
|
|
self.isApplyingRunSnapshot = true
|
|
defer { self.isApplyingRunSnapshot = false }
|
|
self.adoptRun(runId: runId, bufferedText: snapshot.text)
|
|
}
|
|
|
|
private func adoptRun(runId: String, bufferedText: String) {
|
|
let canonicalPendingRuns = Set([runId])
|
|
if self.pendingRuns != canonicalPendingRuns {
|
|
// Gateway snapshots and live deltas are canonical for this session.
|
|
// Replace stale local ownership so only that run consumes later events.
|
|
self.clearPendingRuns(reason: nil)
|
|
self.pendingRuns.insert(runId)
|
|
self.pendingToolCallsById = [:]
|
|
self.updateStreamingAssistantText(nil)
|
|
}
|
|
if self.runMessageScopesByRunID[runId] == nil {
|
|
self.runMessageScopesByRunID[runId] = self.currentRunMessageScope()
|
|
}
|
|
self.armPendingRunTimeout(runId: runId)
|
|
if !bufferedText.isEmpty {
|
|
self.updateStreamingAssistantText(bufferedText)
|
|
}
|
|
self.logDiagnostic(
|
|
"chat.ui adopted in-flight run sessionKey=\(self.sessionKey) "
|
|
+ "runId=\(runId) bufferedTextLen=\(bufferedText.count)")
|
|
}
|
|
|
|
private func startBootstrap(sessionKey requestedSessionKey: String? = nil) {
|
|
let sessionKey = requestedSessionKey ?? self.sessionKey
|
|
guard sessionKey == self.sessionKey else { return }
|
|
self.bootstrapGeneration &+= 1
|
|
self.bootstrapTask?.cancel()
|
|
self.isLoading = true
|
|
self.errorText = nil
|
|
self.healthOK = false
|
|
self.clearPendingRuns(reason: nil)
|
|
self.pendingToolCallsById = [:]
|
|
self.updateStreamingAssistantText(nil)
|
|
self.sessionId = nil
|
|
let historyRequest = self.beginHistoryRequest(captureLatestUserTurn: requestedSessionKey == nil)
|
|
let context = BootstrapContext(
|
|
id: bootstrapGeneration,
|
|
historyRequest: historyRequest)
|
|
self.paintFromCacheIfNeeded(session: context.session)
|
|
self.restoreOutboxMessages(session: context.session)
|
|
self.bootstrapTask = Task { [weak self] in
|
|
guard let self else { return }
|
|
await self.bootstrap(context: context)
|
|
}
|
|
}
|
|
|
|
private func bootstrap(context: BootstrapContext) async {
|
|
guard self.isCurrentBootstrap(context) else { return }
|
|
defer {
|
|
if self.isCurrentBootstrap(context) {
|
|
self.isLoading = false
|
|
}
|
|
}
|
|
do {
|
|
await self.syncActiveSessionSubscription(startingWith: context.session.key)
|
|
guard self.isCurrentBootstrap(context) else { return }
|
|
|
|
let payload = try await transport.requestHistory(sessionKey: context.session.key)
|
|
guard self.isCurrentBootstrap(context) else { return }
|
|
_ = self.applyHistoryPayload(
|
|
payload,
|
|
for: context.historyRequest,
|
|
preservingOptimisticLocalMessages: false,
|
|
syncThinkingOptions: true)
|
|
await 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.isCurrentBootstrap(context) else { return }
|
|
self.errorText = error.localizedDescription
|
|
chatUILogger.error("bootstrap failed \(error.localizedDescription, privacy: .public)")
|
|
}
|
|
}
|
|
|
|
private func syncActiveSessionSubscription(startingWith sessionKey: String) async {
|
|
var nextSessionKey = sessionKey
|
|
while true {
|
|
do {
|
|
// Subscribe requests are gateway side effects. If a stale request finishes
|
|
// after a newer switch, immediately reassert the latest visible session.
|
|
try await self.transport.setActiveSessionKey(nextSessionKey)
|
|
} catch {
|
|
let currentSessionKey = self.sessionKey
|
|
guard currentSessionKey != nextSessionKey else {
|
|
// Best-effort only; history/send/health still work without push events.
|
|
return
|
|
}
|
|
nextSessionKey = currentSessionKey
|
|
continue
|
|
}
|
|
let currentSessionKey = self.sessionKey
|
|
guard currentSessionKey != nextSessionKey else { return }
|
|
nextSessionKey = currentSessionKey
|
|
}
|
|
}
|
|
|
|
private func refreshRunStateAfterForeground() async {
|
|
let context = self.beginHistoryRequest()
|
|
self.logDiagnostic(
|
|
"chat.ui foreground refresh sessionKey=\(context.session.key) "
|
|
+ "pending=\(self.pendingRunCount)")
|
|
let refresh = await self.refreshHistoryAfterRun(historyRequest: context)
|
|
guard self.isCurrentSession(context.session) else { return }
|
|
if refresh.applied,
|
|
refresh.runSnapshotApplied,
|
|
context.runOwnershipGeneration == self.runOwnershipGeneration,
|
|
!self.isSending,
|
|
refresh.supportsInFlightRunState,
|
|
!refresh.hasInFlightRun
|
|
{
|
|
self.clearPendingRuns(
|
|
reason: nil,
|
|
hapticEvent: self.assistantHapticEventAfterLatestUser())
|
|
self.pendingToolCallsById = [:]
|
|
self.updateStreamingAssistantText(nil)
|
|
}
|
|
await self.pollHealthIfNeeded(force: true, sessionSnapshot: context.session)
|
|
}
|
|
|
|
private static let resetTriggers: Set<String> = ["/reset", "/clear"]
|
|
private static let compactTriggers: Set<String> = ["/compact"]
|
|
|
|
private func loadSlashCommands(force: Bool) async {
|
|
guard self.transport.supportsSlashCommandCatalog else { return }
|
|
guard force || !self.hasLoadedSlashCommands else { return }
|
|
guard !self.isLoadingSlashCommands else { return }
|
|
let sessionSnapshot = self.currentSessionSnapshot()
|
|
self.isLoadingSlashCommands = true
|
|
defer { self.isLoadingSlashCommands = false }
|
|
|
|
do {
|
|
let commands = try await self.transport.listCommands(sessionKey: sessionSnapshot.key)
|
|
guard self.isCurrentSession(sessionSnapshot) else { return }
|
|
self.slashCommands = commands
|
|
self.slashFilterCache = nil
|
|
self.slashCommandsErrorText = nil
|
|
self.hasLoadedSlashCommands = true
|
|
} catch {
|
|
guard self.isCurrentSession(sessionSnapshot) else { return }
|
|
self.slashCommandsErrorText = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
private func waitForSlashCommandLoadIfNeeded() async {
|
|
guard self.transport.supportsSlashCommandCatalog else { return }
|
|
if !self.hasLoadedSlashCommands, !self.isLoadingSlashCommands {
|
|
await self.loadSlashCommands(force: false)
|
|
return
|
|
}
|
|
while self.isLoadingSlashCommands {
|
|
do {
|
|
try await Task.sleep(nanoseconds: 50_000_000)
|
|
} catch {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
private func validateSlashCommandDraftForSend(trimmed: String) async -> Bool {
|
|
guard let slashName = Self.slashCommandName(from: trimmed) else {
|
|
return true
|
|
}
|
|
guard !slashName.isEmpty else {
|
|
self.errorText = "Choose a command."
|
|
return false
|
|
}
|
|
|
|
await self.waitForSlashCommandLoadIfNeeded()
|
|
|
|
if self.hasLoadedSlashCommands,
|
|
Self.isKnownSlashCommandText(trimmed, commands: self.slashCommands),
|
|
!self.attachments.isEmpty
|
|
{
|
|
self.errorText = "Commands cannot be sent with attachments."
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
private func resetSlashCommandCatalog() {
|
|
self.slashCommands = []
|
|
self.slashFilterCache = nil
|
|
self.slashCommandsErrorText = nil
|
|
self.hasLoadedSlashCommands = false
|
|
}
|
|
|
|
private static func slashCommandName(from text: String) -> String? {
|
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard trimmed.hasPrefix("/"), !trimmed.hasPrefix("//") else { return nil }
|
|
let body = trimmed.dropFirst()
|
|
guard let rawName = body.split(whereSeparator: { $0.isWhitespace }).first else {
|
|
return ""
|
|
}
|
|
let name = rawName.split(separator: "@", maxSplits: 1, omittingEmptySubsequences: true).first ?? ""
|
|
return String(name).trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
}
|
|
|
|
private static func isKnownSlashCommandText(
|
|
_ text: String,
|
|
commands: [OpenClawChatCommandChoice]) -> Bool
|
|
{
|
|
guard let commandName = self.slashCommandName(from: text), !commandName.isEmpty else {
|
|
return false
|
|
}
|
|
if self.commands(commands, containInvocationName: commandName) {
|
|
return true
|
|
}
|
|
guard commandName == "skill" else { return false }
|
|
let parts = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.split(whereSeparator: { $0.isWhitespace })
|
|
guard parts.count >= 2 else {
|
|
return self.commands(commands, containInvocationName: commandName)
|
|
}
|
|
let skillName = String(parts[1]).lowercased()
|
|
return commands.contains { command in
|
|
command.source == .skill && self.command(command, matchesInvocationName: skillName)
|
|
}
|
|
}
|
|
|
|
private static func commands(
|
|
_ commands: [OpenClawChatCommandChoice],
|
|
containInvocationName name: String) -> Bool
|
|
{
|
|
commands.contains { self.command($0, matchesInvocationName: name) }
|
|
}
|
|
|
|
private static func command(
|
|
_ command: OpenClawChatCommandChoice,
|
|
matchesInvocationName name: String) -> Bool
|
|
{
|
|
let normalizedName = name.lowercased()
|
|
if command.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalizedName {
|
|
return true
|
|
}
|
|
return command.textAliases.contains { alias in
|
|
self.slashCommandName(from: alias) == normalizedName
|
|
}
|
|
}
|
|
|
|
private static func filteredSlashCommands(
|
|
_ commands: [OpenClawChatCommandChoice],
|
|
query rawQuery: String,
|
|
filter: OpenClawChatCommandFilter) -> [OpenClawChatCommandChoice]
|
|
{
|
|
let trimmed = rawQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let query = self.normalizedSlashQuery(trimmed)
|
|
let effectiveFilter: OpenClawChatCommandFilter =
|
|
self.queryTargetsSkills(trimmed) && filter == .all ? .skills : filter
|
|
return commands.enumerated()
|
|
.compactMap { index, command -> (Int, Int, OpenClawChatCommandChoice)? in
|
|
guard self.command(command, isIncludedIn: effectiveFilter) else { return nil }
|
|
guard let rank = self.commandSearchRank(command, query: query) else { return nil }
|
|
return (rank, index, command)
|
|
}
|
|
.sorted {
|
|
if $0.0 != $1.0 { return $0.0 < $1.0 }
|
|
return $0.1 < $1.1
|
|
}
|
|
.map(\.2)
|
|
}
|
|
|
|
private static func normalizedSlashQuery(_ query: String) -> String {
|
|
let withoutSlash = query.hasPrefix("/") ? String(query.dropFirst()) : query
|
|
let lower = withoutSlash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
if lower == "skill" {
|
|
return ""
|
|
}
|
|
if lower.hasPrefix("skill ") {
|
|
return String(lower.dropFirst("skill ".count)).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
return lower
|
|
}
|
|
|
|
private static func queryTargetsSkills(_ query: String) -> Bool {
|
|
let withoutSlash = query.hasPrefix("/") ? String(query.dropFirst()) : query
|
|
let lower = withoutSlash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
return lower == "skill" || lower.hasPrefix("skill ")
|
|
}
|
|
|
|
private static func command(
|
|
_ command: OpenClawChatCommandChoice,
|
|
isIncludedIn filter: OpenClawChatCommandFilter) -> Bool
|
|
{
|
|
switch filter {
|
|
case .all:
|
|
true
|
|
case .commands:
|
|
command.source != .skill
|
|
case .skills:
|
|
command.source == .skill
|
|
}
|
|
}
|
|
|
|
private static func commandSearchRank(
|
|
_ command: OpenClawChatCommandChoice,
|
|
query: String) -> Int?
|
|
{
|
|
guard !query.isEmpty else { return 0 }
|
|
let names = ([command.name, command.preferredInvocation] + command.textAliases)
|
|
.map { candidate in
|
|
let trimmed = candidate.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let withoutSlash = trimmed.hasPrefix("/") ? String(trimmed.dropFirst()) : trimmed
|
|
return withoutSlash.lowercased()
|
|
}
|
|
.filter { !$0.isEmpty }
|
|
if names.contains(where: { $0.hasPrefix(query) }) {
|
|
return 0
|
|
}
|
|
if names.contains(where: { $0.contains(query) }) {
|
|
return 1
|
|
}
|
|
if command.description.lowercased().contains(query) {
|
|
return 2
|
|
}
|
|
if command.source.rawValue.lowercased().contains(query) {
|
|
return 3
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func handleLocalSlashCommandIfNeeded(_ command: String) async -> Bool {
|
|
if command == "/new" {
|
|
self.input = ""
|
|
await self.startNewSession()
|
|
return true
|
|
}
|
|
if Self.resetTriggers.contains(command) {
|
|
self.input = ""
|
|
await self.performReset()
|
|
return true
|
|
}
|
|
if Self.compactTriggers.contains(command) {
|
|
self.input = ""
|
|
await self.performCompact()
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
private func performSend() async {
|
|
guard !self.isSending else {
|
|
self.logDiagnostic("chat.ui send ignored reason=sending sessionKey=\(self.sessionKey)")
|
|
return
|
|
}
|
|
guard self.pendingRuns.isEmpty else {
|
|
self.logDiagnostic(
|
|
"chat.ui send ignored reason=pending sessionKey=\(self.sessionKey) "
|
|
+ "pending=\(self.pendingRunCount)")
|
|
return
|
|
}
|
|
let trimmed = self.input.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty || !self.attachments.isEmpty else {
|
|
self.logDiagnostic("chat.ui send ignored reason=empty sessionKey=\(self.sessionKey)")
|
|
return
|
|
}
|
|
|
|
let command = trimmed.lowercased()
|
|
if await self.handleLocalSlashCommandIfNeeded(command) {
|
|
return
|
|
}
|
|
guard await self.validateSlashCommandDraftForSend(trimmed: trimmed) else {
|
|
return
|
|
}
|
|
|
|
let sessionSnapshot = self.currentSessionSnapshot()
|
|
let sessionKey = sessionSnapshot.key
|
|
|
|
// Raised before the health poll so the entry guard covers the whole
|
|
// async path: a second submit during `pollHealthIfNeeded` would
|
|
// otherwise re-capture the same draft and enqueue a duplicate
|
|
// outbox row.
|
|
self.isSending = true
|
|
defer { self.isSending = false }
|
|
|
|
if !self.healthOK {
|
|
await self.pollHealthIfNeeded(force: true, sessionSnapshot: sessionSnapshot)
|
|
guard self.isCurrentSession(sessionSnapshot) else { return }
|
|
// Offline capture: queue text-only sends durably instead of
|
|
// failing. Attachments stay on the live path (text only in v1).
|
|
if !self.healthOK, self.outbox != nil, self.attachments.isEmpty {
|
|
self.logDiagnostic(
|
|
"chat.ui send queued offline sessionKey=\(sessionKey) inputLen=\(trimmed.count)")
|
|
await self.enqueueOutboxCommand(text: trimmed, session: sessionSnapshot)
|
|
return
|
|
}
|
|
}
|
|
|
|
// FIFO across the reconnect boundary: while this session still has
|
|
// queued/sending outbox rows — or restore has not yet adopted rows
|
|
// persisted by an earlier process, so we must assume a backlog — a
|
|
// live send would race ahead of them. Route it through the outbox so
|
|
// the queue stays the single ordering authority; it flushes
|
|
// immediately while healthy, so the turn still sends right away.
|
|
// Failed rows are parked user decisions and do not hold new sends
|
|
// hostage. Attachment sends stay live (online-only in v1) and are
|
|
// documented as outside the ordering guarantee. Deliberately
|
|
// session-scoped: other sessions' queued rows are separate
|
|
// conversations with no ordering contract against this send.
|
|
if self.outbox != nil, self.attachments.isEmpty,
|
|
!self.hasRestoredOutboxMessages
|
|
|| self.outboxStatesByMessageID.values.contains(where: { !$0.isFailed })
|
|
{
|
|
self.logDiagnostic(
|
|
"chat.ui send routed behind outbox sessionKey=\(sessionKey) inputLen=\(trimmed.count)")
|
|
await self.enqueueOutboxCommand(text: trimmed, session: sessionSnapshot)
|
|
return
|
|
}
|
|
|
|
self.errorText = nil
|
|
let runId = UUID().uuidString
|
|
let messageText = trimmed.isEmpty && !self.attachments.isEmpty ? "See attached." : trimmed
|
|
let thinkingLevel = self.thinkingLevel
|
|
self.pendingRuns.insert(runId)
|
|
self.armPendingRunTimeout(runId: runId)
|
|
self.logDiagnostic(
|
|
"chat.ui send queued sessionKey=\(sessionKey) "
|
|
+ "localRunId=\(runId) pending=\(self.pendingRunCount)")
|
|
self.pendingToolCallsById = [:]
|
|
self.updateStreamingAssistantText(nil)
|
|
|
|
// Optimistically append user message to UI.
|
|
var userContent: [OpenClawChatMessageContent] = [
|
|
OpenClawChatMessageContent(
|
|
type: "text",
|
|
text: messageText,
|
|
thinking: nil,
|
|
thinkingSignature: nil,
|
|
mimeType: nil,
|
|
fileName: nil,
|
|
content: nil,
|
|
id: nil,
|
|
name: nil,
|
|
arguments: nil),
|
|
]
|
|
let encodedAttachments = self.attachments.map { att -> OpenClawChatAttachmentPayload in
|
|
OpenClawChatAttachmentPayload(
|
|
type: att.type,
|
|
mimeType: att.mimeType,
|
|
fileName: att.fileName,
|
|
content: att.data.base64EncodedString())
|
|
}
|
|
for att in encodedAttachments {
|
|
userContent.append(
|
|
OpenClawChatMessageContent(
|
|
type: att.type,
|
|
text: nil,
|
|
thinking: nil,
|
|
thinkingSignature: nil,
|
|
mimeType: att.mimeType,
|
|
fileName: att.fileName,
|
|
content: AnyCodable(att.content),
|
|
id: nil,
|
|
name: nil,
|
|
arguments: nil))
|
|
}
|
|
let userMessageTimestamp = Date().timeIntervalSince1970 * 1000
|
|
let userMessageID = UUID()
|
|
self.appendMessage(
|
|
OpenClawChatMessage(
|
|
id: userMessageID,
|
|
role: "user",
|
|
content: userContent,
|
|
timestamp: userMessageTimestamp,
|
|
idempotencyKey: "\(runId):user"))
|
|
self.pendingLocalUserEchoMessageIDsByRunID[runId] = userMessageID
|
|
self.runMessageScopesByRunID[runId] = self.currentRunMessageScope()
|
|
|
|
// Clear input immediately for responsive UX (before network await)
|
|
self.input = ""
|
|
self.attachments = []
|
|
|
|
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 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)")
|
|
if response.status != "error", response.status != "timeout" {
|
|
self.haptics.perform(.messageSent)
|
|
}
|
|
var reusedRunAlreadyFinal = false
|
|
if response.runId != runId {
|
|
let pendingUserMessageID = self.pendingLocalUserEchoMessageIDsByRunID.removeValue(forKey: runId)
|
|
let localRunScope = self.runMessageScopesByRunID.removeValue(forKey: runId)
|
|
self.clearPendingRun(runId)
|
|
self.pendingRuns.insert(response.runId)
|
|
// The gateway can reuse an identical active run without writing
|
|
// this second turn. Move the optimistic row onto that durable
|
|
// identity, collapsing it if the canonical row is already here.
|
|
let rekeyedUserEcho = self.rekeyLocalUserEcho(
|
|
messageID: pendingUserMessageID,
|
|
runId: response.runId)
|
|
self.pendingLocalUserEchoMessageIDsByRunID[response.runId] = rekeyedUserEcho?.pendingMessageID
|
|
let remoteRunScope = rekeyedUserEcho?.scope ?? localRunScope ?? self.currentRunMessageScope()
|
|
self.runMessageScopesByRunID[response.runId] = remoteRunScope
|
|
self.rescopeProvisionalFinalMessages(runId: response.runId, scope: remoteRunScope)
|
|
reusedRunAlreadyFinal = self.hasRecordedFinalMessage(runId: response.runId)
|
|
if reusedRunAlreadyFinal {
|
|
self.clearPendingRun(response.runId, hapticEvent: .runCompleted)
|
|
self.pendingToolCallsById = [:]
|
|
self.updateStreamingAssistantText(nil)
|
|
} else {
|
|
self.armPendingRunTimeout(runId: response.runId)
|
|
}
|
|
}
|
|
if response.status == "ok" {
|
|
let historyContext = self.beginHistoryRequest(for: sessionSnapshot)
|
|
await self.refreshHistoryAfterRun(historyRequest: historyContext)
|
|
guard self.isCurrentSession(sessionSnapshot) else { return }
|
|
self.finishPendingRunAfterTerminalOkSendAck(response)
|
|
} else if !self.finishPendingRunIfTerminalSendAck(response),
|
|
!reusedRunAlreadyFinal
|
|
{
|
|
let historyContext = self.beginHistoryRequest(for: sessionSnapshot)
|
|
let refresh = await self.refreshHistoryAfterRun(historyRequest: historyContext)
|
|
guard self.isCurrentSession(sessionSnapshot) else { return }
|
|
let hasInFlightRunSnapshot = refresh.applied &&
|
|
refresh.runSnapshotApplied &&
|
|
refresh.hasInFlightRun
|
|
if hasInFlightRunSnapshot ||
|
|
!self.clearPendingRunIfAssistantMessagePresent(
|
|
runId: response.runId,
|
|
after: userMessageTimestamp)
|
|
{
|
|
self.armPostSendRefreshFallback(
|
|
runId: response.runId,
|
|
sessionSnapshot: sessionSnapshot,
|
|
userMessageTimestamp: userMessageTimestamp)
|
|
self.armRunCompletionRefresh(
|
|
runId: response.runId,
|
|
sessionSnapshot: sessionSnapshot,
|
|
userMessageTimestamp: userMessageTimestamp)
|
|
}
|
|
}
|
|
} catch {
|
|
guard self.isCurrentSession(sessionSnapshot) else { return }
|
|
// Stale-healthy disconnects surface here instead of at the send
|
|
// gate. Requeue text-only sends durably (same runId = same
|
|
// idempotency identity, safe even if the send actually landed)
|
|
// and keep the optimistic bubble as the queued row.
|
|
if encodedAttachments.isEmpty {
|
|
self.runMessageScopesByRunID.removeValue(forKey: runId)
|
|
self.clearPendingRun(runId)
|
|
let requeued = await self.requeueFailedLiveSend(
|
|
runId: runId,
|
|
text: messageText,
|
|
thinking: thinkingLevel,
|
|
messageID: userMessageID,
|
|
session: sessionSnapshot)
|
|
if requeued {
|
|
self.logDiagnostic(
|
|
"chat.ui send requeued offline sessionKey=\(sessionKey) "
|
|
+ "localRunId=\(runId) error=\(error.localizedDescription)")
|
|
return
|
|
}
|
|
guard self.isCurrentSession(sessionSnapshot) else { return }
|
|
// Refused requeue (queue full / broken store): restore the
|
|
// draft so the text is not lost with the failed bubble.
|
|
if self.input.isEmpty {
|
|
self.input = messageText
|
|
}
|
|
}
|
|
self.removePendingLocalUserEcho(for: runId)
|
|
self.runMessageScopesByRunID.removeValue(forKey: runId)
|
|
self.errorText = error.localizedDescription
|
|
self.clearPendingRun(runId, hapticEvent: .runFailed)
|
|
self.logDiagnostic(
|
|
"chat.ui send failed sessionKey=\(sessionKey) "
|
|
+ "localRunId=\(runId) error=\(error.localizedDescription)")
|
|
chatUILogger.error("chat transport send failed \(error.localizedDescription, privacy: .public)")
|
|
}
|
|
}
|
|
|
|
private func performAbort() async {
|
|
guard !self.pendingRuns.isEmpty else { return }
|
|
guard !self.isAborting else { return }
|
|
self.isAborting = true
|
|
defer { self.isAborting = false }
|
|
|
|
let runIds = Array(pendingRuns)
|
|
for runId in runIds {
|
|
do {
|
|
try await self.transport.abortRun(sessionKey: self.sessionKey, runId: runId)
|
|
} catch {
|
|
// Best-effort.
|
|
}
|
|
}
|
|
}
|
|
|
|
private func fetchSessions(limit: Int?, sessionSnapshot: SessionSnapshot? = nil) async {
|
|
do {
|
|
let res = try await transport.listSessions(limit: limit)
|
|
if let sessionSnapshot, !self.isCurrentSession(sessionSnapshot) { return }
|
|
self.sessions = res.sessions
|
|
self.sessionDefaults = res.defaults
|
|
self.hasAppliedLiveSessions = true
|
|
self.syncSelectedModel()
|
|
self.syncThinkingLevelOptions()
|
|
self.persistSessionsToCache(res.sessions)
|
|
} catch {
|
|
// Best-effort.
|
|
}
|
|
}
|
|
|
|
private func fetchModels(sessionSnapshot: SessionSnapshot? = nil) async {
|
|
do {
|
|
let modelChoices = try await transport.listModels()
|
|
if let sessionSnapshot, !self.isCurrentSession(sessionSnapshot) { return }
|
|
self.modelChoices = modelChoices
|
|
self.syncSelectedModel()
|
|
} catch {
|
|
// Best-effort.
|
|
}
|
|
}
|
|
|
|
private func applySessionSwitch(to sessionKey: String, intent: SessionSwitchIntent) {
|
|
let next = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !next.isEmpty else { return }
|
|
guard next != self.sessionKey else { return }
|
|
self.advanceSessionGeneration()
|
|
self.sessionKey = next
|
|
if intent == .userInitiated {
|
|
self.onSessionChanged?(next)
|
|
}
|
|
self.clearSessionOwnedState()
|
|
self.startBootstrap(sessionKey: next)
|
|
}
|
|
|
|
private func performStartNewSession(worktree: Bool) async {
|
|
let requested = self.generatedNewSessionKey()
|
|
let parentSessionKey = self.sessionKey
|
|
let next: String
|
|
do {
|
|
let created = try await transport.createSession(
|
|
key: requested,
|
|
label: nil,
|
|
parentSessionKey: parentSessionKey,
|
|
worktree: worktree ? true : nil)
|
|
let createdKey = created.key.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
next = createdKey.isEmpty ? requested : createdKey
|
|
} catch {
|
|
if Self.isUnsupportedCreateSessionError(error) {
|
|
chatUILogger.info("sessions.create unsupported; falling back to sessions.reset")
|
|
await self.performReset()
|
|
return
|
|
}
|
|
chatUILogger.error("sessions.create failed \(error.localizedDescription, privacy: .public)")
|
|
self.errorText = error.localizedDescription
|
|
return
|
|
}
|
|
self.advanceSessionGeneration()
|
|
self.sessionKey = next
|
|
self.onSessionChanged?(next)
|
|
self.clearSessionOwnedState()
|
|
self.errorText = nil
|
|
self.startBootstrap()
|
|
}
|
|
|
|
/// Clears state owned by the current session/agent before a new identity can consume events.
|
|
private func clearSessionOwnedState() {
|
|
self.modelSelectionID = Self.defaultModelSelectionID
|
|
self.replaceMessages([])
|
|
self.isShowingCachedTranscript = false
|
|
self.hasAppliedLiveHistory = false
|
|
self.pendingLocalUserEchoMessageIDsByRunID.removeAll()
|
|
self.runMessageScopesByRunID.removeAll()
|
|
self.provisionalFinalMessagesByID.removeAll()
|
|
self.recentlySentOutboxUserKeys.removeAll()
|
|
self.resetOutboxPresentationForSessionSwitch()
|
|
self.sessionId = nil
|
|
self.pendingToolCallsById = [:]
|
|
self.updateStreamingAssistantText(nil)
|
|
self.resetSlashCommandCatalog()
|
|
self.clearPendingRuns(reason: nil)
|
|
}
|
|
|
|
private static func isUnsupportedCreateSessionError(_ error: Error) -> Bool {
|
|
let nsError = error as NSError
|
|
return nsError.domain == "OpenClawChatTransport"
|
|
&& nsError.localizedDescription == "sessions.create not supported by this transport"
|
|
}
|
|
|
|
private func performReset() async {
|
|
self.isLoading = true
|
|
self.errorText = nil
|
|
|
|
do {
|
|
try await self.transport.resetSession(sessionKey: self.sessionKey)
|
|
} catch {
|
|
self.isLoading = false
|
|
self.errorText = error.localizedDescription
|
|
chatUILogger.error("session reset failed \(error.localizedDescription, privacy: .public)")
|
|
return
|
|
}
|
|
|
|
self.runMessageScopesByRunID.removeAll()
|
|
self.provisionalFinalMessagesByID.removeAll()
|
|
self.startBootstrap()
|
|
}
|
|
|
|
private func performCompact() async {
|
|
guard !self.isCompacting else { return }
|
|
guard !self.isSending, self.pendingRuns.isEmpty, !self.isAborting else {
|
|
self.errorText = "Wait for the current response before compacting the session."
|
|
return
|
|
}
|
|
if let lastCompactAt,
|
|
Date().timeIntervalSince(lastCompactAt) < compactCooldown
|
|
{
|
|
self.errorText = "Please wait before compacting this session again."
|
|
return
|
|
}
|
|
|
|
self.isCompacting = true
|
|
self.isLoading = true
|
|
self.errorText = nil
|
|
defer {
|
|
self.isCompacting = false
|
|
}
|
|
|
|
do {
|
|
try await self.transport.compactSession(sessionKey: self.sessionKey)
|
|
} catch {
|
|
self.isLoading = false
|
|
self.errorText = "Unable to compact the session. Please try again."
|
|
let nsError = error as NSError
|
|
chatUILogger.error(
|
|
"compact failed domain=\(nsError.domain, privacy: .public) code=\(nsError.code, privacy: .public)")
|
|
chatUILogger.error("compact details=\(String(describing: error), privacy: .private)")
|
|
return
|
|
}
|
|
|
|
lastCompactAt = Date()
|
|
self.startBootstrap()
|
|
}
|
|
|
|
private func performSelectThinkingLevel(_ level: String) async {
|
|
let next = Self.normalizedThinkingLevel(level) ?? "off"
|
|
guard next != self.thinkingLevel else { return }
|
|
|
|
let sessionKey = self.sessionKey
|
|
self.thinkingLevel = next
|
|
self.syncThinkingLevelOptions()
|
|
self.updateCurrentSessionThinkingLevel(next, sessionKey: sessionKey)
|
|
self.onThinkingLevelChanged?(next)
|
|
self.nextThinkingSelectionRequestID &+= 1
|
|
let requestID = self.nextThinkingSelectionRequestID
|
|
self.latestThinkingSelectionRequestIDsBySession[sessionKey] = requestID
|
|
self.latestThinkingLevelsBySession[sessionKey] = next
|
|
|
|
do {
|
|
try await self.transport.setSessionThinking(sessionKey: sessionKey, thinkingLevel: next)
|
|
guard requestID == self.latestThinkingSelectionRequestIDsBySession[sessionKey] else {
|
|
let latest = self.latestThinkingLevelsBySession[sessionKey] ?? next
|
|
guard latest != next else { return }
|
|
try? await self.transport.setSessionThinking(sessionKey: sessionKey, thinkingLevel: latest)
|
|
return
|
|
}
|
|
} catch {
|
|
guard sessionKey == self.sessionKey,
|
|
requestID == self.latestThinkingSelectionRequestIDsBySession[sessionKey]
|
|
else { return }
|
|
// Best-effort. Persisting the user's local preference matters more than a patch error here.
|
|
}
|
|
}
|
|
|
|
private func performSelectModel(_ selectionID: String) async {
|
|
let next = self.normalizedSelectionID(selectionID)
|
|
guard next != self.modelSelectionID else { return }
|
|
|
|
let sessionKey = self.sessionKey
|
|
let previous = self.modelSelectionID
|
|
let previousRequestID = self.latestModelSelectionRequestIDsBySession[sessionKey]
|
|
self.nextModelSelectionRequestID &+= 1
|
|
let requestID = self.nextModelSelectionRequestID
|
|
let nextModelRef = self.modelRef(forSelectionID: next)
|
|
self.latestModelSelectionRequestIDsBySession[sessionKey] = requestID
|
|
self.latestModelSelectionIDsBySession[sessionKey] = next
|
|
self.beginModelPatch(for: sessionKey)
|
|
self.modelSelectionID = next
|
|
self.errorText = nil
|
|
defer { self.endModelPatch(for: sessionKey) }
|
|
|
|
do {
|
|
try await self.transport.setSessionModel(
|
|
sessionKey: sessionKey,
|
|
model: nextModelRef)
|
|
guard requestID == self.latestModelSelectionRequestIDsBySession[sessionKey] else {
|
|
// Keep older successful patches as rollback state, but do not replay
|
|
// stale UI/session state over a newer in-flight or completed selection.
|
|
self.lastSuccessfulModelSelectionIDsBySession[sessionKey] = next
|
|
return
|
|
}
|
|
self.applySuccessfulModelSelection(next, sessionKey: sessionKey, syncSelection: true)
|
|
} catch {
|
|
guard requestID == self.latestModelSelectionRequestIDsBySession[sessionKey] else { return }
|
|
self.latestModelSelectionIDsBySession[sessionKey] = previous
|
|
if let previousRequestID {
|
|
self.latestModelSelectionRequestIDsBySession[sessionKey] = previousRequestID
|
|
} else {
|
|
self.latestModelSelectionRequestIDsBySession.removeValue(forKey: sessionKey)
|
|
}
|
|
if self.lastSuccessfulModelSelectionIDsBySession[sessionKey] == previous {
|
|
self.applySuccessfulModelSelection(
|
|
previous,
|
|
sessionKey: sessionKey,
|
|
syncSelection: sessionKey == self.sessionKey)
|
|
}
|
|
guard sessionKey == self.sessionKey else { return }
|
|
self.modelSelectionID = previous
|
|
self.errorText = error.localizedDescription
|
|
chatUILogger.error("sessions.patch(model) failed \(error.localizedDescription, privacy: .public)")
|
|
}
|
|
}
|
|
|
|
private func beginModelPatch(for sessionKey: String) {
|
|
self.inFlightModelPatchCountsBySession[sessionKey, default: 0] += 1
|
|
}
|
|
|
|
private func endModelPatch(for sessionKey: String) {
|
|
let remaining = max(0, (inFlightModelPatchCountsBySession[sessionKey] ?? 0) - 1)
|
|
if remaining == 0 {
|
|
self.inFlightModelPatchCountsBySession.removeValue(forKey: sessionKey)
|
|
let waiters = self.modelPatchWaitersBySession.removeValue(forKey: sessionKey) ?? []
|
|
for waiter in waiters {
|
|
waiter.resume()
|
|
}
|
|
return
|
|
}
|
|
self.inFlightModelPatchCountsBySession[sessionKey] = remaining
|
|
}
|
|
|
|
/// Internal for the outbox flush, which must honor the same ordering
|
|
/// behind in-flight model patches as the live send path.
|
|
func waitForPendingModelPatches(in sessionKey: String) async {
|
|
guard (self.inFlightModelPatchCountsBySession[sessionKey] ?? 0) > 0 else { return }
|
|
await withCheckedContinuation { continuation in
|
|
self.modelPatchWaitersBySession[sessionKey, default: []].append(continuation)
|
|
}
|
|
}
|
|
|
|
func placeholderSession(key: String) -> OpenClawChatSessionEntry {
|
|
OpenClawChatSessionEntry(
|
|
key: key,
|
|
kind: nil,
|
|
displayName: nil,
|
|
surface: nil,
|
|
subject: nil,
|
|
room: nil,
|
|
space: nil,
|
|
updatedAt: nil,
|
|
sessionId: nil,
|
|
systemSent: nil,
|
|
abortedLastRun: nil,
|
|
thinkingLevel: nil,
|
|
verboseLevel: nil,
|
|
inputTokens: nil,
|
|
outputTokens: nil,
|
|
totalTokens: nil,
|
|
modelProvider: nil,
|
|
model: nil,
|
|
contextTokens: nil)
|
|
}
|
|
|
|
private func syncSelectedModel() {
|
|
let currentSession = self.sessions.first(where: { $0.key == self.sessionKey })
|
|
let explicitModelID = self.normalizedModelSelectionID(
|
|
currentSession?.model,
|
|
provider: currentSession?.modelProvider)
|
|
if let explicitModelID {
|
|
self.lastSuccessfulModelSelectionIDsBySession[self.sessionKey] = explicitModelID
|
|
self.modelSelectionID = explicitModelID
|
|
return
|
|
}
|
|
self.lastSuccessfulModelSelectionIDsBySession[self.sessionKey] = Self.defaultModelSelectionID
|
|
self.modelSelectionID = Self.defaultModelSelectionID
|
|
}
|
|
|
|
private func normalizedSelectionID(_ selectionID: String) -> String {
|
|
let trimmed = selectionID.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return Self.defaultModelSelectionID }
|
|
return trimmed
|
|
}
|
|
|
|
private func normalizedModelSelectionID(_ modelID: String?, provider: String? = nil) -> String? {
|
|
guard let modelID else { return nil }
|
|
let trimmed = modelID.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return nil }
|
|
if let provider = Self.normalizedProvider(provider) {
|
|
let providerQualified = Self.providerQualifiedModelSelectionID(modelID: trimmed, provider: provider)
|
|
if let match = modelChoices.first(where: {
|
|
$0.selectionID == providerQualified ||
|
|
($0.modelID == trimmed && Self.normalizedProvider($0.provider) == provider)
|
|
}) {
|
|
return match.selectionID
|
|
}
|
|
return providerQualified
|
|
}
|
|
if self.modelChoices.contains(where: { $0.selectionID == trimmed }) {
|
|
return trimmed
|
|
}
|
|
let matches = self.modelChoices.filter { $0.modelID == trimmed || $0.selectionID == trimmed }
|
|
if matches.count == 1 {
|
|
return matches[0].selectionID
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
private func modelRef(forSelectionID selectionID: String) -> String? {
|
|
let normalized = self.normalizedSelectionID(selectionID)
|
|
if normalized == Self.defaultModelSelectionID {
|
|
return nil
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
private func generatedNewSessionKey() -> String {
|
|
let baseKey = "ios-\(UUID().uuidString.lowercased())"
|
|
guard let agentID = Self.agentID(fromSessionKey: sessionKey) ??
|
|
self.activeAgentId ??
|
|
Self.agentID(fromSessionKey: resolvedMainSessionKey) ??
|
|
sessions.lazy.compactMap({ Self.agentID(fromSessionKey: $0.key) }).first
|
|
else {
|
|
return baseKey
|
|
}
|
|
return "agent:\(agentID):\(baseKey)"
|
|
}
|
|
|
|
private static func agentID(fromSessionKey sessionKey: String) -> String? {
|
|
let parts = sessionKey
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.split(separator: ":", omittingEmptySubsequences: false)
|
|
guard parts.count >= 3, parts[0].lowercased() == "agent" else { return nil }
|
|
let agentID = String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return agentID.isEmpty ? nil : agentID
|
|
}
|
|
|
|
private func modelLabel(for modelID: String) -> String {
|
|
self.modelChoices.first(where: { $0.selectionID == modelID || $0.modelID == modelID })?.displayLabel ??
|
|
modelID
|
|
}
|
|
|
|
private func applySuccessfulModelSelection(_ selectionID: String, sessionKey: String, syncSelection: Bool) {
|
|
self.lastSuccessfulModelSelectionIDsBySession[sessionKey] = selectionID
|
|
let resolved = self.resolvedSessionModelIdentity(forSelectionID: selectionID)
|
|
self.updateCurrentSessionModel(
|
|
modelID: resolved.modelID,
|
|
modelProvider: resolved.modelProvider,
|
|
sessionKey: sessionKey,
|
|
syncSelection: syncSelection)
|
|
if sessionKey == self.sessionKey {
|
|
self.syncThinkingLevelOptions()
|
|
}
|
|
}
|
|
|
|
private func resolvedSessionModelIdentity(forSelectionID selectionID: String)
|
|
-> (modelID: String?, modelProvider: String?)
|
|
{
|
|
guard let modelRef = modelRef(forSelectionID: selectionID) else {
|
|
return (nil, nil)
|
|
}
|
|
if let choice = modelChoices.first(where: { $0.selectionID == modelRef }) {
|
|
return (choice.modelID, Self.normalizedProvider(choice.provider))
|
|
}
|
|
return (modelRef, nil)
|
|
}
|
|
|
|
private static func normalizedProvider(_ provider: String?) -> String? {
|
|
let trimmed = provider?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard let trimmed, !trimmed.isEmpty else { return nil }
|
|
return trimmed
|
|
}
|
|
|
|
private static func providerQualifiedModelSelectionID(modelID: String, provider: String) -> String {
|
|
let providerPrefix = "\(provider)/"
|
|
if modelID.hasPrefix(providerPrefix) {
|
|
return modelID
|
|
}
|
|
return "\(provider)/\(modelID)"
|
|
}
|
|
|
|
private func updateCurrentSessionThinkingLevel(_ thinkingLevel: String?, sessionKey: String) {
|
|
guard let index = sessions.firstIndex(where: { $0.key == sessionKey }) else { return }
|
|
let current = self.sessions[index]
|
|
self.sessions[index] = OpenClawChatSessionEntry(
|
|
key: current.key,
|
|
kind: current.kind,
|
|
displayName: current.displayName,
|
|
surface: current.surface,
|
|
subject: current.subject,
|
|
room: current.room,
|
|
space: current.space,
|
|
updatedAt: current.updatedAt,
|
|
sessionId: current.sessionId,
|
|
systemSent: current.systemSent,
|
|
abortedLastRun: current.abortedLastRun,
|
|
thinkingLevel: thinkingLevel,
|
|
verboseLevel: current.verboseLevel,
|
|
inputTokens: current.inputTokens,
|
|
outputTokens: current.outputTokens,
|
|
totalTokens: current.totalTokens,
|
|
modelProvider: current.modelProvider,
|
|
model: current.model,
|
|
contextTokens: current.contextTokens,
|
|
thinkingLevels: current.thinkingLevels,
|
|
thinkingOptions: current.thinkingOptions,
|
|
thinkingDefault: current.thinkingDefault)
|
|
}
|
|
|
|
private func updateCurrentSessionModel(
|
|
modelID: String?,
|
|
modelProvider: String?,
|
|
sessionKey: String,
|
|
syncSelection: Bool)
|
|
{
|
|
if let index = sessions.firstIndex(where: { $0.key == sessionKey }) {
|
|
let current = self.sessions[index]
|
|
self.sessions[index] = OpenClawChatSessionEntry(
|
|
key: current.key,
|
|
kind: current.kind,
|
|
displayName: current.displayName,
|
|
surface: current.surface,
|
|
subject: current.subject,
|
|
room: current.room,
|
|
space: current.space,
|
|
updatedAt: current.updatedAt,
|
|
sessionId: current.sessionId,
|
|
systemSent: current.systemSent,
|
|
abortedLastRun: current.abortedLastRun,
|
|
thinkingLevel: current.thinkingLevel,
|
|
verboseLevel: current.verboseLevel,
|
|
inputTokens: current.inputTokens,
|
|
outputTokens: current.outputTokens,
|
|
totalTokens: current.totalTokens,
|
|
modelProvider: modelProvider,
|
|
model: modelID,
|
|
contextTokens: current.contextTokens)
|
|
} else {
|
|
let placeholder = self.placeholderSession(key: sessionKey)
|
|
self.sessions.append(
|
|
OpenClawChatSessionEntry(
|
|
key: placeholder.key,
|
|
kind: placeholder.kind,
|
|
displayName: placeholder.displayName,
|
|
surface: placeholder.surface,
|
|
subject: placeholder.subject,
|
|
room: placeholder.room,
|
|
space: placeholder.space,
|
|
updatedAt: placeholder.updatedAt,
|
|
sessionId: placeholder.sessionId,
|
|
systemSent: placeholder.systemSent,
|
|
abortedLastRun: placeholder.abortedLastRun,
|
|
thinkingLevel: placeholder.thinkingLevel,
|
|
verboseLevel: placeholder.verboseLevel,
|
|
inputTokens: placeholder.inputTokens,
|
|
outputTokens: placeholder.outputTokens,
|
|
totalTokens: placeholder.totalTokens,
|
|
modelProvider: modelProvider,
|
|
model: modelID,
|
|
contextTokens: placeholder.contextTokens))
|
|
}
|
|
if syncSelection {
|
|
self.syncSelectedModel()
|
|
}
|
|
}
|
|
|
|
private func handleTransportEvent(_ evt: OpenClawChatTransportEvent) {
|
|
switch evt {
|
|
case let .health(ok):
|
|
self.applyTransportHealth(ok)
|
|
case .tick:
|
|
let context = self.currentSessionSnapshot()
|
|
Task { await self.pollHealthIfNeeded(force: false, sessionSnapshot: context) }
|
|
case let .chat(chat):
|
|
self.handleChatEvent(chat)
|
|
case let .sessionMessage(message):
|
|
self.handleSessionMessageEvent(message)
|
|
case let .agent(agent):
|
|
self.handleAgentEvent(agent)
|
|
case .seqGap:
|
|
self.errorText = nil
|
|
self.invalidateHistorySnapshots()
|
|
self.invalidateRunSnapshots()
|
|
self.clearPendingRuns(reason: nil)
|
|
self.pendingToolCallsById = [:]
|
|
self.updateStreamingAssistantText(nil)
|
|
let context = self.beginHistoryRequest()
|
|
Task {
|
|
await self.refreshHistoryAfterRun(historyRequest: context)
|
|
await self.pollHealthIfNeeded(force: true, sessionSnapshot: context.session)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleSessionMessageEvent(_ payload: OpenClawSessionMessageEventPayload) {
|
|
if let sessionKey = payload.sessionKey,
|
|
!self.matchesCurrentSessionKey(incoming: sessionKey, agentId: payload.agentId, current: self.sessionKey)
|
|
{
|
|
return
|
|
}
|
|
|
|
guard let message = payload.message else { return }
|
|
|
|
let sanitized = Self.stripInboundMetadata(from: message)
|
|
self.invalidateHistorySnapshots()
|
|
// The active client also receives the gateway's echo of the user turn it
|
|
// just sent. performSend already appended an optimistic row carrying a
|
|
// local client timestamp, while the echo carries a server timestamp, so
|
|
// the timestamp-keyed identity/dedupe paths below never collapse them.
|
|
// Adopt the server record onto the exactly correlated row even when the
|
|
// run's final event already cleared pending state. Same-content turns
|
|
// without this key remain distinct.
|
|
if self.adoptCorrelatedUserMessage(incoming: sanitized) {
|
|
return
|
|
}
|
|
if self.adoptProvisionalFinalMessage(incoming: sanitized) {
|
|
return
|
|
}
|
|
|
|
let reconciled = Self.reconcileMessageIDs(previous: self.messages, incoming: self.messages + [sanitized])
|
|
self.replaceMessages(Self.dedupeMessages(reconciled))
|
|
self.pruneProvisionalFinalMessages()
|
|
self.pruneRunMessageScopes()
|
|
}
|
|
|
|
private func handleChatEvent(_ chat: OpenClawChatEventPayload) {
|
|
let isOurRun = chat.runId.flatMap { self.pendingRuns.contains($0) } ?? false
|
|
if let runId = chat.runId {
|
|
self.logDiagnostic(
|
|
"chat.ui event chat state=\(chat.state ?? "unknown") "
|
|
+ "runId=\(runId) ours=\(isOurRun) pending=\(self.pendingRunCount)")
|
|
}
|
|
|
|
// Gateway may publish canonical session keys (for example "agent:main:main")
|
|
// even when this view currently uses an alias key (for example "main").
|
|
// Never drop events for our own pending run on key mismatch, or the UI can stay
|
|
// stuck at "thinking" until the user reopens and forces a history reload.
|
|
if let sessionKey = chat.sessionKey,
|
|
!self.matchesCurrentSessionKey(
|
|
incoming: sessionKey,
|
|
agentId: chat.agentId,
|
|
current: self.sessionKey),
|
|
!isOurRun
|
|
{
|
|
return
|
|
}
|
|
if chat.state == "delta",
|
|
let runId = Self.normalizedRunID(chat.runId)
|
|
{
|
|
guard self.pendingRuns.isEmpty || self.pendingRuns.contains(runId) else {
|
|
return
|
|
}
|
|
self.invalidateRunSnapshots()
|
|
self.adoptRun(
|
|
runId: runId,
|
|
bufferedText: OpenClawChatEventText.assistantText(from: chat) ?? "")
|
|
return
|
|
}
|
|
if chat.state == "final" || chat.state == "aborted" || chat.state == "error" {
|
|
self.invalidateHistorySnapshots()
|
|
}
|
|
self.invalidateRunSnapshots()
|
|
if !isOurRun {
|
|
// Keep multiple clients in sync: if another client finishes a run for our session, refresh history.
|
|
switch chat.state {
|
|
case "final", "aborted", "error":
|
|
self.updateStreamingAssistantText(nil)
|
|
self.pendingToolCallsById = [:]
|
|
self.appendFinalChatMessageIfPresent(chat)
|
|
let context = self.beginHistoryRequest()
|
|
Task { await self.refreshHistoryAfterRun(historyRequest: context) }
|
|
default:
|
|
break
|
|
}
|
|
return
|
|
}
|
|
|
|
switch chat.state {
|
|
case "final", "aborted", "error":
|
|
if chat.state == "error" {
|
|
self.errorText = chat.errorMessage ?? "Chat failed"
|
|
}
|
|
let hapticEvent: OpenClawChatHaptics.Event? = switch chat.state {
|
|
case "final": .runCompleted
|
|
case "error": .runFailed
|
|
default: nil
|
|
}
|
|
if let runId = chat.runId {
|
|
self.clearPendingRun(runId, hapticEvent: hapticEvent)
|
|
} else if self.pendingRuns.count <= 1 {
|
|
self.clearPendingRuns(reason: nil, hapticEvent: hapticEvent)
|
|
}
|
|
self.pendingToolCallsById = [:]
|
|
self.updateStreamingAssistantText(nil)
|
|
self.appendFinalChatMessageIfPresent(chat)
|
|
let context = self.beginHistoryRequest()
|
|
Task { await self.refreshHistoryAfterRun(historyRequest: context) }
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func appendFinalChatMessageIfPresent(_ chat: OpenClawChatEventPayload) {
|
|
guard chat.state == "final" else { return }
|
|
guard let text = OpenClawChatEventText.assistantText(from: chat) else { return }
|
|
|
|
let decoded = chat.message.flatMap {
|
|
try? ChatPayloadDecoding.decode($0, as: OpenClawChatMessage.self)
|
|
}
|
|
let message = if let decoded,
|
|
Self.isAssistantMessage(decoded)
|
|
{
|
|
Self.messageWithTimestampIfNeeded(decoded)
|
|
} else {
|
|
OpenClawChatMessage(
|
|
role: "assistant",
|
|
content: [
|
|
OpenClawChatMessageContent(
|
|
type: "text",
|
|
text: text,
|
|
thinking: nil,
|
|
thinkingSignature: nil,
|
|
mimeType: nil,
|
|
fileName: nil,
|
|
content: nil,
|
|
id: nil,
|
|
name: nil,
|
|
arguments: nil),
|
|
],
|
|
timestamp: Date().timeIntervalSince1970 * 1000,
|
|
stopReason: "stop")
|
|
}
|
|
|
|
let runId = Self.normalizedRunID(chat.runId)
|
|
let scope = self.runMessageScope(for: runId)
|
|
guard self.isCurrentSession(scope.session) else { return }
|
|
guard let reconciliationKey = Self.finalMessageReconciliationKey(for: message) else { return }
|
|
if let runId, self.hasRecordedFinalMessage(runId: runId) {
|
|
return
|
|
}
|
|
|
|
if self.hasCanonicalFinalMessageMatching(message, scope: scope) {
|
|
if let runId {
|
|
self.runMessageScopesByRunID.removeValue(forKey: runId)
|
|
}
|
|
return
|
|
}
|
|
|
|
let reconciled = Self.reconcileMessageIDs(previous: self.messages, incoming: self.messages + [message])
|
|
self.replaceMessages(Self.dedupeMessages(reconciled))
|
|
if self.messages.contains(where: { $0.id == message.id }) {
|
|
self.provisionalFinalMessagesByID[message.id] = ProvisionalFinalMessage(
|
|
reconciliationKey: reconciliationKey,
|
|
runId: runId,
|
|
scope: scope)
|
|
}
|
|
self.pruneProvisionalFinalMessages()
|
|
self.pruneRunMessageScopes()
|
|
}
|
|
|
|
static func isAssistantMessage(_ message: OpenClawChatMessage) -> Bool {
|
|
message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "assistant"
|
|
}
|
|
|
|
private static func messageWithTimestampIfNeeded(_ message: OpenClawChatMessage) -> OpenClawChatMessage {
|
|
guard message.timestamp == nil else { return message }
|
|
return OpenClawChatMessage(
|
|
id: message.id,
|
|
role: message.role,
|
|
content: message.content,
|
|
timestamp: Date().timeIntervalSince1970 * 1000,
|
|
idempotencyKey: message.idempotencyKey,
|
|
toolCallId: message.toolCallId,
|
|
toolName: message.toolName,
|
|
usage: message.usage,
|
|
stopReason: message.stopReason,
|
|
errorMessage: message.errorMessage)
|
|
}
|
|
|
|
private func handleAgentEvent(_ evt: OpenClawAgentEventPayload) {
|
|
let isPendingRun = self.pendingRuns.contains(evt.runId)
|
|
let isLegacySessionStream = self.pendingRuns.isEmpty && self.sessionId == evt.runId
|
|
if !isPendingRun, !isLegacySessionStream {
|
|
return
|
|
}
|
|
self.invalidateRunSnapshots()
|
|
self.logDiagnostic(
|
|
"chat.ui event agent stream=\(evt.stream) "
|
|
+ "runId=\(evt.runId) pending=\(self.pendingRunCount)")
|
|
|
|
switch evt.stream {
|
|
case "assistant":
|
|
if let text = evt.data["text"]?.value as? String {
|
|
self.updateStreamingAssistantText(text)
|
|
}
|
|
case "lifecycle":
|
|
self.handleAgentLifecycleEvent(evt, isPendingRun: isPendingRun)
|
|
case "tool":
|
|
guard let phase = evt.data["phase"]?.value as? String else { return }
|
|
guard let name = evt.data["name"]?.value as? String else { return }
|
|
guard let toolCallId = evt.data["toolCallId"]?.value as? String else { return }
|
|
if phase == "start" {
|
|
let args = evt.data["args"]
|
|
self.pendingToolCallsById[toolCallId] = OpenClawChatPendingToolCall(
|
|
toolCallId: toolCallId,
|
|
name: name,
|
|
args: args,
|
|
startedAt: evt.ts.map(Double.init) ?? Date().timeIntervalSince1970 * 1000,
|
|
isError: nil)
|
|
} else if phase == "result" {
|
|
self.pendingToolCallsById[toolCallId] = nil
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func handleAgentLifecycleEvent(_ evt: OpenClawAgentEventPayload, isPendingRun: Bool) {
|
|
let phase = Self.lowercasedAgentEventString(evt.data["phase"])
|
|
let status = Self.lowercasedAgentEventString(evt.data["status"])
|
|
let aborted = Self.agentEventBool(evt.data["aborted"])
|
|
let isFailure =
|
|
phase == "error" || phase == "failed" || phase == "aborted" ||
|
|
status == "error" || status == "failed" || status == "aborted"
|
|
let isSuccessfulStatus =
|
|
status == "ok" || status == "success" || status == "succeeded" ||
|
|
status == "complete" || status == "completed"
|
|
let isTerminalPhase = phase == "end" || phase == "complete" || phase == "completed"
|
|
|
|
guard isTerminalPhase || isFailure || aborted || isSuccessfulStatus else { return }
|
|
|
|
self.invalidateHistorySnapshots()
|
|
|
|
if isFailure || aborted {
|
|
self.errorText = Self.agentLifecycleErrorMessage(evt, aborted: aborted)
|
|
}
|
|
if isPendingRun {
|
|
self.clearPendingRun(
|
|
evt.runId,
|
|
hapticEvent: isFailure || aborted ? .runFailed : .runCompleted)
|
|
}
|
|
self.pendingToolCallsById = [:]
|
|
self.updateStreamingAssistantText(nil)
|
|
let context = self.beginHistoryRequest()
|
|
Task { await self.refreshHistoryAfterRun(historyRequest: context) }
|
|
}
|
|
|
|
private static func lowercasedAgentEventString(_ value: AnyCodable?) -> String? {
|
|
(value?.value as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
}
|
|
|
|
private static func agentEventBool(_ value: AnyCodable?) -> Bool {
|
|
if let boolValue = value?.value as? Bool {
|
|
return boolValue
|
|
}
|
|
guard let stringValue = lowercasedAgentEventString(value) else {
|
|
return false
|
|
}
|
|
return stringValue == "true" || stringValue == "yes" || stringValue == "1"
|
|
}
|
|
|
|
private static func agentLifecycleErrorMessage(_ evt: OpenClawAgentEventPayload, aborted: Bool) -> String {
|
|
if aborted {
|
|
return "Run aborted"
|
|
}
|
|
if let message = evt.data["error"]?.value as? String,
|
|
!message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
{
|
|
return message
|
|
}
|
|
if let message = evt.data["message"]?.value as? String,
|
|
!message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
{
|
|
return message
|
|
}
|
|
return "Chat failed"
|
|
}
|
|
|
|
private func finishPendingRunAfterTerminalOkSendAck(_ response: OpenClawChatSendResponse) {
|
|
self.clearPendingRun(response.runId, hapticEvent: .runCompleted)
|
|
self.pendingToolCallsById = [:]
|
|
self.updateStreamingAssistantText(nil)
|
|
self.logDiagnostic(
|
|
"chat.ui send terminal ack sessionKey=\(self.sessionKey) "
|
|
+ "runId=\(response.runId) status=ok")
|
|
}
|
|
|
|
private func finishPendingRunIfTerminalSendAck(_ response: OpenClawChatSendResponse) -> Bool {
|
|
switch response.status {
|
|
case "timeout":
|
|
self.removePendingLocalUserEcho(for: response.runId)
|
|
self.pendingToolCallsById = [:]
|
|
self.updateStreamingAssistantText(nil)
|
|
self.errorText = "Chat failed before the run started; try again."
|
|
self.clearPendingRun(response.runId, hapticEvent: .runFailed)
|
|
self.logDiagnostic(
|
|
"chat.ui send terminal ack sessionKey=\(self.sessionKey) "
|
|
+ "runId=\(response.runId) status=timeout")
|
|
return true
|
|
case "error":
|
|
self.removePendingLocalUserEcho(for: response.runId)
|
|
self.pendingToolCallsById = [:]
|
|
self.updateStreamingAssistantText(nil)
|
|
self.errorText = "Chat failed before the run started; try again."
|
|
self.clearPendingRun(response.runId, hapticEvent: .runFailed)
|
|
self.logDiagnostic(
|
|
"chat.ui send terminal ack sessionKey=\(self.sessionKey) "
|
|
+ "runId=\(response.runId) status=error")
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private func removePendingLocalUserEcho(for runId: String) {
|
|
guard let messageID = pendingLocalUserEchoMessageIDsByRunID[runId] else { return }
|
|
self.removeMessage(id: messageID)
|
|
self.pendingLocalUserEchoMessageIDsByRunID[runId] = nil
|
|
}
|
|
|
|
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,
|
|
sessionSnapshot: sessionSnapshot,
|
|
after: userMessageTimestamp,
|
|
diagnostic: "chat.ui refresh fallback sessionKey=\(sessionSnapshot.key) "
|
|
+ "runId=\(runId) delayMs=\(delayMs)")
|
|
guard shouldContinue == true else {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
sessionSnapshot: sessionSnapshot,
|
|
after: userMessageTimestamp,
|
|
diagnostic: "chat.ui run completion refresh sessionKey=\(sessionSnapshot.key) "
|
|
+ "runId=\(runId)")
|
|
}
|
|
}
|
|
|
|
private func refreshIfPending(
|
|
runId: String,
|
|
sessionSnapshot: SessionSnapshot,
|
|
after timestamp: Double,
|
|
diagnostic: String) async -> Bool
|
|
{
|
|
guard self.isCurrentSession(sessionSnapshot),
|
|
self.pendingRuns.contains(runId)
|
|
else {
|
|
return false
|
|
}
|
|
self.logDiagnostic(diagnostic)
|
|
let historyContext = self.beginHistoryRequest(for: sessionSnapshot)
|
|
let refresh = await self.refreshHistoryAfterRun(historyRequest: historyContext)
|
|
guard self.isCurrentSession(sessionSnapshot),
|
|
self.pendingRuns.contains(runId)
|
|
else {
|
|
return false
|
|
}
|
|
if refresh.applied, refresh.runSnapshotApplied, refresh.supportsInFlightRunState {
|
|
if refresh.hasInFlightRun {
|
|
return true
|
|
}
|
|
self.clearPendingRun(runId)
|
|
self.pendingToolCallsById = [:]
|
|
self.updateStreamingAssistantText(nil)
|
|
return false
|
|
}
|
|
return !self.clearPendingRunIfAssistantMessagePresent(runId: runId, after: timestamp)
|
|
}
|
|
|
|
@discardableResult
|
|
private func clearPendingRunIfAssistantMessagePresent(runId: String, after timestamp: Double) -> Bool {
|
|
guard let hapticEvent = self.assistantHapticEvent(after: timestamp) else { return false }
|
|
self.clearPendingRun(runId, hapticEvent: hapticEvent)
|
|
self.pendingToolCallsById = [:]
|
|
self.updateStreamingAssistantText(nil)
|
|
return true
|
|
}
|
|
|
|
private static func hasUnansweredLatestUser(in messages: [OpenClawChatMessage]) -> Bool {
|
|
self.latestUserTurn(in: messages) != nil && !self.hasAssistantMessageAfterLatestUser(in: messages)
|
|
}
|
|
|
|
static func latestUserTurn(in messages: [OpenClawChatMessage]) -> LatestUserTurn? {
|
|
guard let lastUserIndex = messages.lastIndex(where: { $0.role.lowercased() == "user" }) else {
|
|
return nil
|
|
}
|
|
return self.userTurn(at: lastUserIndex, in: messages)
|
|
}
|
|
|
|
static func userTurn(
|
|
at userIndex: [OpenClawChatMessage].Index,
|
|
in messages: [OpenClawChatMessage]) -> LatestUserTurn?
|
|
{
|
|
guard messages.indices.contains(userIndex),
|
|
messages[userIndex].role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user"
|
|
else {
|
|
return nil
|
|
}
|
|
guard let refreshKey = userRefreshIdentityKey(for: messages[userIndex]) else {
|
|
return LatestUserTurn(
|
|
idempotencyKey: self.normalizedIdempotencyKey(messages[userIndex].idempotencyKey),
|
|
refreshKey: nil,
|
|
occurrence: 0,
|
|
timestamp: messages[userIndex].timestamp)
|
|
}
|
|
let occurrence = messages[...userIndex].reduce(into: 0) { count, message in
|
|
guard self.userRefreshIdentityKey(for: message) == refreshKey else { return }
|
|
count += 1
|
|
}
|
|
return LatestUserTurn(
|
|
idempotencyKey: Self.normalizedIdempotencyKey(messages[userIndex].idempotencyKey),
|
|
refreshKey: refreshKey,
|
|
occurrence: occurrence,
|
|
timestamp: messages[userIndex].timestamp)
|
|
}
|
|
|
|
private static func hasAnsweredUser(
|
|
_ user: LatestUserTurn,
|
|
in messages: [OpenClawChatMessage])
|
|
-> Bool
|
|
{
|
|
// Hooks may transform persisted user content while preserving this key.
|
|
// Prefer the durable turn identity so a completed refresh rejects older history.
|
|
if let idempotencyKey = user.idempotencyKey {
|
|
guard let userIndex = messages.lastIndex(where: { message in
|
|
message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user" &&
|
|
self.normalizedIdempotencyKey(message.idempotencyKey) == idempotencyKey
|
|
}) else {
|
|
return false
|
|
}
|
|
return self.hasAssistantMessage(after: userIndex, in: messages)
|
|
}
|
|
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 }
|
|
return self.hasAssistantMessage(after: index, in: messages)
|
|
}
|
|
guard let latestMatchingUserIndex,
|
|
messages.lastIndex(where: { $0.role.lowercased() == "user" }) == latestMatchingUserIndex
|
|
else {
|
|
return false
|
|
}
|
|
if let requestTimestamp = user.timestamp,
|
|
let latestTimestamp = messages[latestMatchingUserIndex].timestamp,
|
|
latestTimestamp < requestTimestamp
|
|
{
|
|
return false
|
|
}
|
|
return self.hasAssistantMessage(after: latestMatchingUserIndex, in: messages)
|
|
}
|
|
|
|
private static func hasAssistantMessage(
|
|
after userIndex: [OpenClawChatMessage].Index,
|
|
in messages: [OpenClawChatMessage]) -> Bool
|
|
{
|
|
let nextIndex = messages.index(after: userIndex)
|
|
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)
|
|
return !text.isEmpty || message.errorMessage != nil
|
|
}
|
|
}
|
|
|
|
private static func assistantHapticEvent(
|
|
for message: OpenClawChatMessage) -> OpenClawChatHaptics.Event?
|
|
{
|
|
guard message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "assistant" else {
|
|
return nil
|
|
}
|
|
let text = message.content.compactMap(\.text).joined(separator: "\n")
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !text.isEmpty || message.errorMessage != nil else { return nil }
|
|
let stopReason = message.stopReason?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
return stopReason == "error" || stopReason == "aborted" ? .runFailed : .runCompleted
|
|
}
|
|
|
|
private func assistantHapticEventAfterLatestUser() -> OpenClawChatHaptics.Event? {
|
|
guard let userIndex = self.messages.lastIndex(where: { $0.role.lowercased() == "user" }) else { return nil }
|
|
let nextIndex = self.messages.index(after: userIndex)
|
|
guard nextIndex < self.messages.endIndex else { return nil }
|
|
return self.messages[nextIndex...].reversed().lazy.compactMap(Self.assistantHapticEvent).first
|
|
}
|
|
|
|
private func assistantHapticEvent(after timestamp: Double) -> OpenClawChatHaptics.Event? {
|
|
self.messages.reversed().lazy.compactMap { message in
|
|
guard (message.timestamp ?? 0) >= timestamp else { return nil }
|
|
return Self.assistantHapticEvent(for: message)
|
|
}.first
|
|
}
|
|
|
|
/// Narrow seam for the outbox extension: pull durable history after a
|
|
/// flush so acked commands reconcile with their queued bubbles.
|
|
func refreshHistoryAfterOutboxFlush() async {
|
|
await self.refreshHistoryAfterRun()
|
|
}
|
|
|
|
@discardableResult
|
|
private func refreshHistoryAfterRun(historyRequest request: HistoryRequest? = nil) async
|
|
-> (applied: Bool, runSnapshotApplied: Bool, supportsInFlightRunState: Bool, hasInFlightRun: Bool)
|
|
{
|
|
let request = request ?? self.beginHistoryRequest()
|
|
do {
|
|
let payload = try await transport.requestHistory(sessionKey: request.session.key)
|
|
let runSnapshotApplied = request.runOwnershipGeneration == self.runOwnershipGeneration &&
|
|
request.id >= self.latestAppliedRunSnapshotRequestID
|
|
let applied = self.applyHistoryPayload(
|
|
payload,
|
|
for: request,
|
|
preservingOptimisticLocalMessages: true)
|
|
let hasInFlightRun = Self.normalizedRunID(payload.inFlightRun?.runId) != nil
|
|
// `hasActiveRun` is session-wide and can be true for an embedded agent run.
|
|
// Its presence capability-gates an authoritative missing chat snapshot, but
|
|
// only `inFlightRun` establishes ownership of the pending chat run.
|
|
let supportsInFlightRunState = hasInFlightRun || payload.sessionInfo?.hasActiveRun != nil
|
|
return (
|
|
applied,
|
|
applied && runSnapshotApplied,
|
|
supportsInFlightRunState,
|
|
hasInFlightRun)
|
|
} catch {
|
|
chatUILogger.error("refresh history failed \(error.localizedDescription, privacy: .public)")
|
|
return (false, false, false, false)
|
|
}
|
|
}
|
|
|
|
private func armPendingRunTimeout(runId: String) {
|
|
self.pendingRunTimeoutTasks[runId]?.cancel()
|
|
self.nextPendingRunTimeoutArmID &+= 1
|
|
let armID = self.nextPendingRunTimeoutArmID
|
|
self.pendingRunTimeoutArmIDs[runId] = armID
|
|
self.pendingRunTimeoutTasks[runId] = Task { [weak self] in
|
|
let timeoutMs = await MainActor.run { self?.pendingRunTimeoutMs ?? 0 }
|
|
do {
|
|
try await Task.sleep(nanoseconds: timeoutMs * 1_000_000)
|
|
} catch {
|
|
// Rearming or completing a run cancels this task. Never let the
|
|
// retired timeout clear the still-active replacement owner.
|
|
return
|
|
}
|
|
guard !Task.isCancelled else { return }
|
|
await MainActor.run { [weak self] in
|
|
guard let self else { return }
|
|
guard self.pendingRunTimeoutArmIDs[runId] == armID else { return }
|
|
guard self.pendingRuns.contains(runId) else { return }
|
|
self.logDiagnostic(
|
|
"chat.ui pending timeout sessionKey=\(self.sessionKey) "
|
|
+ "runId=\(runId)")
|
|
self.errorText = "Timed out waiting for a reply; try again or refresh."
|
|
self.clearPendingRun(runId, hapticEvent: .runFailed)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func clearPendingRun(
|
|
_ runId: String,
|
|
hapticEvent: OpenClawChatHaptics.Event? = nil)
|
|
{
|
|
let wasPending = self.pendingRuns.contains(runId)
|
|
self.pendingRuns.remove(runId)
|
|
self.pendingLocalUserEchoMessageIDsByRunID[runId] = nil
|
|
self.pendingRunTimeoutTasks[runId]?.cancel()
|
|
self.pendingRunTimeoutTasks[runId] = nil
|
|
self.pendingRunTimeoutArmIDs[runId] = nil
|
|
if wasPending {
|
|
self.logDiagnostic(
|
|
"chat.ui pending cleared sessionKey=\(self.sessionKey) "
|
|
+ "runId=\(runId)")
|
|
if self.pendingRuns.isEmpty, let hapticEvent {
|
|
self.haptics.perform(hapticEvent)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func clearPendingRuns(
|
|
reason: String?,
|
|
hapticEvent: OpenClawChatHaptics.Event? = nil)
|
|
{
|
|
let runIds = Array(pendingRuns)
|
|
for runId in self.pendingRuns {
|
|
self.pendingRunTimeoutTasks[runId]?.cancel()
|
|
}
|
|
self.pendingRunTimeoutTasks.removeAll()
|
|
self.pendingRunTimeoutArmIDs.removeAll()
|
|
self.pendingRuns.removeAll()
|
|
self.pendingLocalUserEchoMessageIDsByRunID.removeAll()
|
|
if !runIds.isEmpty, let hapticEvent {
|
|
self.haptics.perform(hapticEvent)
|
|
}
|
|
if let reason, !reason.isEmpty {
|
|
self.errorText = reason
|
|
for runId in runIds {
|
|
self.logDiagnostic(
|
|
"chat.ui pending cleared sessionKey=\(self.sessionKey) "
|
|
+ "runId=\(runId) reason=\(reason)")
|
|
}
|
|
}
|
|
}
|
|
}
|