mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-16 22:41:38 +00:00
* fix(apple): prevent stale model thinking state * test(apple): complete attachment transport fixture * fix(apple): scope model patch ordering by route identity * chore(apple): sync native CI metadata
749 lines
32 KiB
Swift
749 lines
32 KiB
Swift
import Foundation
|
|
import OpenClawKit
|
|
import OSLog
|
|
|
|
private let transportEventsLogger = Logger(subsystem: "ai.openclaw", category: "OpenClawChatUI")
|
|
|
|
extension OpenClawChatViewModel {
|
|
func handleTransportEvent(_ evt: OpenClawChatTransportEvent) {
|
|
switch evt {
|
|
case let .health(ok):
|
|
applyTransportHealth(ok)
|
|
case .tick:
|
|
let context = self.currentSessionSnapshot()
|
|
Task { await self.pollHealthIfNeeded(force: false, sessionSnapshot: context) }
|
|
case let .sessionsChanged(change):
|
|
guard change.reason == "patch" || change.reason == "command-metadata" else { return }
|
|
let context = self.currentSessionSnapshot()
|
|
Task { await self.fetchSessions(limit: 50, 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) {
|
|
guard let message = payload.message else { return }
|
|
let sanitized = Self.stripInboundMetadata(from: message)
|
|
let isCurrentSession = payload.sessionKey.map {
|
|
self.matchesCurrentSessionKey(incoming: $0, agentId: payload.agentId, current: self.sessionKey)
|
|
} ?? true
|
|
// Confirmation is gateway-scoped, not presentation-scoped. A flush
|
|
// can drain session A while session B is visible, and A's event must
|
|
// still retire its durable row before this handler returns early.
|
|
confirmOutboxCommands(in: [sanitized])
|
|
guard isCurrentSession else { return }
|
|
|
|
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 adoptCorrelatedUserMessage(incoming: sanitized) {
|
|
self.clearActiveSessionRunIndicatorIfLatestUserAnswered()
|
|
self.applyDeferredExternalStateIfReady()
|
|
return
|
|
}
|
|
if adoptProvisionalFinalMessage(incoming: sanitized) {
|
|
self.clearActiveSessionRunIndicatorIfLatestUserAnswered()
|
|
return
|
|
}
|
|
|
|
let reconciled = Self.reconcileMessageIDs(previous: self.messages, incoming: self.messages + [sanitized])
|
|
replaceMessages(Self.dedupeMessages(reconciled))
|
|
pruneProvisionalFinalMessages()
|
|
pruneRunMessageScopes()
|
|
self.clearActiveSessionRunIndicatorIfLatestUserAnswered()
|
|
self.applyDeferredExternalStateIfReady()
|
|
}
|
|
|
|
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.updateActiveSessionRunWithoutChatSnapshot(false)
|
|
}
|
|
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()
|
|
self.applyDeferredExternalStateIfReady()
|
|
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 = runMessageScope(for: runId)
|
|
guard self.isCurrentSession(scope.session) else { return }
|
|
guard let reconciliationKey = Self.finalMessageReconciliationKey(for: message) else { return }
|
|
if let runId, hasRecordedFinalMessage(runId: runId) {
|
|
return
|
|
}
|
|
|
|
if hasCanonicalFinalMessageMatching(message, scope: scope) {
|
|
if let runId {
|
|
self.runMessageScopesByRunID.removeValue(forKey: runId)
|
|
}
|
|
return
|
|
}
|
|
|
|
let reconciled = Self.reconcileMessageIDs(previous: self.messages, incoming: self.messages + [message])
|
|
replaceMessages(Self.dedupeMessages(reconciled))
|
|
if self.messages.contains(where: { $0.id == message.id }) {
|
|
self.provisionalFinalMessagesByID[message.id] = ProvisionalFinalMessage(
|
|
reconciliationKey: reconciliationKey,
|
|
runId: runId,
|
|
scope: scope)
|
|
}
|
|
pruneProvisionalFinalMessages()
|
|
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.updateActiveSessionRunWithoutChatSnapshot(false)
|
|
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" {
|
|
self.updateActiveSessionRunWithoutChatSnapshot(false)
|
|
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()
|
|
self.updateActiveSessionRunWithoutChatSnapshot(false)
|
|
|
|
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()
|
|
self.applyDeferredExternalStateIfReady()
|
|
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"
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
func removePendingLocalUserEcho(for runId: String) {
|
|
guard let messageID = pendingLocalUserEchoMessageIDsByRunID[runId] else { return }
|
|
self.removeMessage(id: messageID)
|
|
self.pendingLocalUserEchoMessageIDsByRunID[runId] = nil
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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 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
|
|
}
|
|
if refresh.sessionHasActiveRun,
|
|
Self.hasUnansweredLatestUser(in: self.messages)
|
|
{
|
|
// A session-level active bit cannot identify a new chat run,
|
|
// but it is enough to retain the run ID this client already owns.
|
|
self.pendingToolCallsById = [:]
|
|
self.updateStreamingAssistantText(nil)
|
|
return true
|
|
}
|
|
self.clearPendingRun(runId)
|
|
self.pendingToolCallsById = [:]
|
|
self.updateStreamingAssistantText(nil)
|
|
return false
|
|
}
|
|
return !self.clearPendingRunIfAssistantMessagePresent(runId: runId, after: timestamp)
|
|
}
|
|
|
|
@discardableResult
|
|
func clearPendingRunIfAssistantMessagePresent(runId: String, after timestamp: Double) -> Bool {
|
|
guard let hapticEvent = assistantHapticEvent(after: timestamp) else { return false }
|
|
self.clearPendingRun(runId, hapticEvent: hapticEvent)
|
|
self.pendingToolCallsById = [:]
|
|
self.updateStreamingAssistantText(nil)
|
|
return true
|
|
}
|
|
|
|
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: 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)
|
|
}
|
|
|
|
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 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
|
|
}
|
|
|
|
func assistantHapticEventAfterLatestUser() -> OpenClawChatHaptics.Event? {
|
|
guard let userIndex = 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
|
|
}
|
|
|
|
/// Pull canonical history for every session touched by one route-bound
|
|
/// outbox pass. Background sessions only retire confirmed rows; the
|
|
/// visible session also runs the normal reconciliation/cache pipeline.
|
|
func refreshHistoriesAfterOutboxFlush(
|
|
targets: Set<OutboxDeliveryTarget>,
|
|
routeLease: OpenClawChatTransportRouteLease) async
|
|
{
|
|
let sortedTargets = targets.sorted { lhs, rhs in
|
|
if lhs.deliverySessionKey != rhs.deliverySessionKey {
|
|
return lhs.deliverySessionKey < rhs.deliverySessionKey
|
|
}
|
|
return (lhs.agentID ?? "") < (rhs.agentID ?? "")
|
|
}
|
|
for target in sortedTargets {
|
|
let visibleRequest = matchesCurrentSessionKey(
|
|
incoming: target.presentationSessionKey,
|
|
agentId: target.agentID,
|
|
current: self.sessionKey)
|
|
? self.beginHistoryRequest()
|
|
: nil
|
|
do {
|
|
let payload = try await routeLease.requestHistory(
|
|
sessionKey: target.deliverySessionKey,
|
|
agentID: target.agentID)
|
|
let incoming = Self.decodeMessages(payload.messages ?? [])
|
|
await confirmOutboxCommandsNow(in: incoming)
|
|
if let visibleRequest {
|
|
_ = self.applyHistoryPayload(
|
|
payload,
|
|
for: visibleRequest,
|
|
preservingOptimisticLocalMessages: true)
|
|
}
|
|
} catch is CancellationError {
|
|
// The gateway route changed during confirmation. Keep every
|
|
// unconfirmed row durable for a later matching reconnect.
|
|
applyTransportHealth(false)
|
|
return
|
|
} catch {
|
|
self.logDiagnostic(
|
|
"chat.ui outbox history failed sessionKey=\(target.deliverySessionKey) "
|
|
+ "error=\(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
func refreshHistoryAfterRun(historyRequest request: HistoryRequest? = nil) async
|
|
-> RunHistoryRefreshResult
|
|
{
|
|
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
|
|
let sessionHasActiveRun = payload.sessionInfo?.hasActiveRun == true
|
|
// `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 RunHistoryRefreshResult(
|
|
applied: applied,
|
|
runSnapshotApplied: applied && runSnapshotApplied,
|
|
supportsInFlightRunState: supportsInFlightRunState,
|
|
hasInFlightRun: hasInFlightRun,
|
|
sessionHasActiveRun: sessionHasActiveRun)
|
|
} catch {
|
|
transportEventsLogger.error("refresh history failed \(error.localizedDescription, privacy: .public)")
|
|
return .failed
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)")
|
|
}
|
|
}
|
|
}
|
|
}
|