mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-23 16:31:14 +00:00
The gateway already broadcasts stream:"plan" agent events with typed
{step, status} snapshots; all three clients dropped them. Control UI now
renders an expandable checklist bar above the composer (run-scoped,
cleared on run lifecycle); the shared iOS/macOS chat UI mounts a
Liquid Glass pill (iOS 26 glassEffect, material fallback) between the
message list and composer; Android adds an expandable pill above the
composer bridged through ChatController/NodeRuntime/MainViewModel.
All parsers accept typed and legacy string steps, enforce the
single-in_progress invariant, and scope snapshots and clears to the
owning run. Locale artifacts, Android resources, and the iOS string
catalog carry generated translations for the pill strings.
711 lines
29 KiB
Swift
711 lines
29 KiB
Swift
import Foundation
|
|
import OpenClawKit
|
|
import OSLog
|
|
|
|
private let chatSendingLogger = Logger(subsystem: "ai.openclaw", category: "OpenClawChatUI")
|
|
|
|
extension OpenClawChatViewModel {
|
|
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 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 = 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
|
|
}
|
|
|
|
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 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, hasAttachments: Bool) 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),
|
|
hasAttachments
|
|
{
|
|
self.errorText = "Commands cannot be sent with attachments."
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
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 = 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, draftInput: String) async -> Bool {
|
|
if command == "/new" {
|
|
if self.input == draftInput { self.input = "" }
|
|
await self.performStartNewSession(worktree: false)
|
|
return true
|
|
}
|
|
if Self.resetTriggers.contains(command) {
|
|
if self.input == draftInput { self.input = "" }
|
|
await self.performReset()
|
|
return true
|
|
}
|
|
if Self.compactTriggers.contains(command) {
|
|
if self.input == draftInput { self.input = "" }
|
|
await self.performCompact()
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
private static func isLiveOnlyLocalSlashCommand(_ command: String) -> Bool {
|
|
command == "/new" || self.resetTriggers.contains(command) || self.compactTriggers.contains(command)
|
|
}
|
|
|
|
private func prepareLiveOnlyLocalSlashCommand(session: SessionSnapshot) async -> Bool {
|
|
// Always probe: a preserved view model can retain stale healthy state
|
|
// after its transport disconnects without a health event. performSend
|
|
// owns the send gate across this await.
|
|
await pollHealthIfNeeded(force: true, sessionSnapshot: session)
|
|
guard self.isCurrentSession(session) else { return false }
|
|
guard self.healthOK else {
|
|
self.errorText = "Connect to the gateway to run this command."
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
private struct SendDraft {
|
|
let input: String
|
|
let attachments: [OpenClawPendingAttachment]
|
|
let trimmed: String
|
|
let session: SessionSnapshot
|
|
|
|
var messageText: String {
|
|
self.trimmed.isEmpty && !self.attachments.isEmpty ? "See attached." : self.trimmed
|
|
}
|
|
}
|
|
|
|
private struct LiveSendAttempt {
|
|
let draft: SendDraft
|
|
let runId: String
|
|
let storedThinkingLevel: String
|
|
let encodedAttachments: [OpenClawChatAttachmentPayload]
|
|
let userMessageTimestamp: Double
|
|
let userMessageID: UUID
|
|
}
|
|
|
|
private enum AttachmentPersistenceDecision {
|
|
case stop
|
|
case persistIfAvailable
|
|
case liveOnly
|
|
}
|
|
|
|
private func performSend() async {
|
|
guard let draft = self.captureSendDraft() else { return }
|
|
|
|
// Own every asynchronous validation/probe below. Slash catalog lookup
|
|
// can suspend, so taking this gate later permits duplicate enqueues.
|
|
// Keep it separate from isSending: local /compact checks that flag.
|
|
self.isSubmittingDraft = true
|
|
defer { self.isSubmittingDraft = false }
|
|
|
|
guard await self.validateSendDraft(draft) else { return }
|
|
|
|
self.isSending = true
|
|
self.isSendingAttachmentDraft = !draft.attachments.isEmpty
|
|
defer {
|
|
self.isSendingAttachmentDraft = false
|
|
self.isSending = false
|
|
self.applyDeferredExternalStateIfReady()
|
|
}
|
|
|
|
guard await self.prepareLiveRoute(for: draft) else { return }
|
|
let attempt = self.beginLiveSend(draft)
|
|
await self.deliverLiveSend(attempt)
|
|
}
|
|
|
|
private func captureSendDraft() -> SendDraft? {
|
|
guard !self.isSubmittingDraft, !self.isSending else {
|
|
self.logDiagnostic("chat.ui send ignored reason=sending sessionKey=\(self.sessionKey)")
|
|
return nil
|
|
}
|
|
guard !self.hasBlockingRunActivity else {
|
|
self.logDiagnostic(
|
|
"chat.ui send ignored reason=pending sessionKey=\(self.sessionKey) "
|
|
+ "pending=\(self.pendingRunCount) "
|
|
+ "activeWithoutSnapshot=\(self.hasActiveSessionRunWithoutChatSnapshot)")
|
|
return nil
|
|
}
|
|
let input = self.input
|
|
let attachments = self.attachments
|
|
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty || !attachments.isEmpty else {
|
|
self.logDiagnostic("chat.ui send ignored reason=empty sessionKey=\(self.sessionKey)")
|
|
return nil
|
|
}
|
|
return SendDraft(
|
|
input: input,
|
|
attachments: attachments,
|
|
trimmed: trimmed,
|
|
session: self.currentSessionSnapshot())
|
|
}
|
|
|
|
private func validateSendDraft(_ draft: SendDraft) async -> Bool {
|
|
let command = draft.trimmed.lowercased()
|
|
if Self.isLiveOnlyLocalSlashCommand(command) {
|
|
let canRunCommand = await self.prepareLiveOnlyLocalSlashCommand(session: draft.session)
|
|
guard canRunCommand else { return false }
|
|
}
|
|
if await self.handleLocalSlashCommandIfNeeded(command, draftInput: draft.input) {
|
|
return false
|
|
}
|
|
return await self.validateSlashCommandDraftForSend(
|
|
trimmed: draft.trimmed,
|
|
hasAttachments: !draft.attachments.isEmpty)
|
|
}
|
|
|
|
private func prepareLiveRoute(for draft: SendDraft) async -> Bool {
|
|
let sessionKey = draft.session.key
|
|
if !self.healthOK {
|
|
await self.pollHealthIfNeeded(force: true, sessionSnapshot: draft.session)
|
|
guard self.isCurrentSession(draft.session) else { return false }
|
|
// Offline capture: queue the full draft durably instead of
|
|
// dropping user text or attachment bytes.
|
|
if !self.healthOK, self.outbox != nil {
|
|
self.logDiagnostic(
|
|
"chat.ui send queued offline sessionKey=\(sessionKey) inputLen=\(draft.trimmed.count)")
|
|
await self.enqueueOutboxCommand(
|
|
text: draft.messageText,
|
|
draftInput: draft.input,
|
|
draftAttachments: draft.attachments,
|
|
session: draft.session)
|
|
return false
|
|
}
|
|
}
|
|
|
|
let mustPreserveOutboxOrder = !self.hasRestoredOutboxMessages ||
|
|
self.outboxStatesByMessageID.values.contains(where: { !$0.isFailed })
|
|
let attachmentDecision = await self.attachmentPersistenceDecision(
|
|
draft,
|
|
mustPreserveOutboxOrder: mustPreserveOutboxOrder)
|
|
let shouldPersistAttachmentDraft: Bool
|
|
switch attachmentDecision {
|
|
case .stop:
|
|
return false
|
|
case .persistIfAvailable:
|
|
shouldPersistAttachmentDraft = true
|
|
case .liveOnly:
|
|
shouldPersistAttachmentDraft = false
|
|
}
|
|
|
|
// 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. Outbox-backed attachments always take this persist-first
|
|
// path so a crash cannot erase their only remaining bytes. Deliberately
|
|
// session-scoped: other sessions are separate conversations with no
|
|
// ordering contract.
|
|
if self.outbox != nil,
|
|
shouldPersistAttachmentDraft || mustPreserveOutboxOrder
|
|
{
|
|
self.logDiagnostic(
|
|
"chat.ui send routed behind outbox sessionKey=\(sessionKey) inputLen=\(draft.trimmed.count)")
|
|
await self.enqueueOutboxCommand(
|
|
text: draft.messageText,
|
|
draftInput: draft.input,
|
|
draftAttachments: draft.attachments,
|
|
session: draft.session)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
private func attachmentPersistenceDecision(
|
|
_ draft: SendDraft,
|
|
mustPreserveOutboxOrder: Bool) async -> AttachmentPersistenceDecision
|
|
{
|
|
guard !draft.attachments.isEmpty,
|
|
self.healthOK,
|
|
self.outbox != nil
|
|
else {
|
|
return draft.attachments.isEmpty ? .liveOnly : .persistIfAvailable
|
|
}
|
|
let routeResult = await self.transport.acquireOutboxRouteLease()
|
|
guard self.isCurrentSession(draft.session) else { return .stop }
|
|
guard case let .unavailable(reason) = routeResult,
|
|
reason == OpenClawChatTransportUpgradeMessage.routingContract
|
|
else {
|
|
return .persistIfAvailable
|
|
}
|
|
guard self.hasRestoredOutboxMessages else {
|
|
self.errorText = "Restoring queued messages. Try again in a moment."
|
|
return .stop
|
|
}
|
|
guard !mustPreserveOutboxOrder else {
|
|
// A legacy gateway cannot drain the existing durable rows, so keep
|
|
// this new attachment in the composer behind them.
|
|
self.errorText = reason
|
|
return .stop
|
|
}
|
|
// Older healthy gateways can send attachments live but cannot safely
|
|
// replay them. Preserve that shipped live-only path.
|
|
return .liveOnly
|
|
}
|
|
|
|
private func beginLiveSend(_ draft: SendDraft) -> LiveSendAttempt {
|
|
self.errorText = nil
|
|
let runId = UUID().uuidString
|
|
let storedThinkingLevel = self.preferredThinkingLevel
|
|
self.pendingRuns.insert(runId)
|
|
self.logDiagnostic(
|
|
"chat.ui send queued sessionKey=\(draft.session.key) "
|
|
+ "localRunId=\(runId) pending=\(self.pendingRunCount)")
|
|
self.pendingToolCallsById = [:]
|
|
self.updateStreamingAssistantText(nil)
|
|
self.clearPlan()
|
|
|
|
// Production attachment sends enter the durable outbox above. Fixture,
|
|
// preview, and embedded transports may intentionally have no outbox;
|
|
// keep their established live-only attachment path available.
|
|
let encodedAttachments = draft.attachments.map { attachment in
|
|
OpenClawChatAttachmentPayload(
|
|
type: attachment.type,
|
|
mimeType: attachment.mimeType,
|
|
fileName: attachment.fileName,
|
|
content: attachment.data.base64EncodedString())
|
|
}
|
|
let userContent = Self.userContent(
|
|
messageText: draft.messageText,
|
|
attachments: draft.attachments,
|
|
encodedAttachments: encodedAttachments)
|
|
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).
|
|
if self.input == draft.input {
|
|
self.input = ""
|
|
}
|
|
let sentAttachmentIDs = Set(draft.attachments.map(\.id))
|
|
self.attachments.removeAll { sentAttachmentIDs.contains($0.id) }
|
|
|
|
return LiveSendAttempt(
|
|
draft: draft,
|
|
runId: runId,
|
|
storedThinkingLevel: storedThinkingLevel,
|
|
encodedAttachments: encodedAttachments,
|
|
userMessageTimestamp: userMessageTimestamp,
|
|
userMessageID: userMessageID)
|
|
}
|
|
|
|
private static func userContent(
|
|
messageText: String,
|
|
attachments: [OpenClawPendingAttachment],
|
|
encodedAttachments: [OpenClawChatAttachmentPayload]) -> [OpenClawChatMessageContent]
|
|
{
|
|
var content: [OpenClawChatMessageContent] = [
|
|
OpenClawChatMessageContent(
|
|
type: "text",
|
|
text: messageText,
|
|
thinking: nil,
|
|
thinkingSignature: nil,
|
|
mimeType: nil,
|
|
fileName: nil,
|
|
content: nil,
|
|
id: nil,
|
|
name: nil,
|
|
arguments: nil),
|
|
]
|
|
for (attachment, payload) in zip(attachments, encodedAttachments) {
|
|
content.append(
|
|
OpenClawChatMessageContent(
|
|
type: payload.type,
|
|
text: nil,
|
|
thinking: nil,
|
|
thinkingSignature: nil,
|
|
mimeType: payload.mimeType,
|
|
fileName: payload.fileName,
|
|
durationSeconds: attachment.durationSeconds,
|
|
content: AnyCodable(payload.content),
|
|
id: nil,
|
|
name: nil,
|
|
arguments: nil))
|
|
}
|
|
return content
|
|
}
|
|
|
|
private func deliverLiveSend(_ attempt: LiveSendAttempt) async {
|
|
let sessionKey = attempt.draft.session.key
|
|
do {
|
|
await self.waitForPendingSessionSettings(in: sessionKey)
|
|
guard self.isCurrentSession(attempt.draft.session) else { return }
|
|
self.logDiagnostic(
|
|
"chat.ui transport send start sessionKey=\(sessionKey) "
|
|
+ "localRunId=\(attempt.runId)")
|
|
let thinkingLevel = self.effectiveThinkingLevelForSend(attempt.storedThinkingLevel)
|
|
let response = try await self.transport.sendMessage(
|
|
sessionKey: sessionKey,
|
|
agentID: attempt.draft.session.deliveryAgentID,
|
|
expectedSessionRoutingContract: attempt.draft.session.sessionRoutingContract,
|
|
message: attempt.draft.messageText,
|
|
thinking: thinkingLevel,
|
|
idempotencyKey: attempt.runId,
|
|
attachments: attempt.encodedAttachments)
|
|
guard self.isCurrentSession(attempt.draft.session) else { return }
|
|
await self.handleLiveSendResponse(response, attempt: attempt)
|
|
} catch {
|
|
await self.handleLiveSendFailure(error, attempt: attempt)
|
|
}
|
|
}
|
|
|
|
private func handleLiveSendResponse(
|
|
_ response: OpenClawChatSendResponse,
|
|
attempt: LiveSendAttempt) async
|
|
{
|
|
let sessionKey = attempt.draft.session.key
|
|
self.logDiagnostic(
|
|
"chat.ui transport send accepted sessionKey=\(sessionKey) "
|
|
+ "localRunId=\(attempt.runId) remoteRunId=\(response.runId)")
|
|
if response.status != "error", response.status != "timeout" {
|
|
self.haptics.perform(.messageSent)
|
|
}
|
|
let reusedRunAlreadyFinal = response.runId == attempt.runId
|
|
? false
|
|
: self.adoptRemoteRunID(response.runId, replacing: attempt.runId)
|
|
|
|
if response.status == "ok" {
|
|
let historyContext = self.beginHistoryRequest(for: attempt.draft.session)
|
|
await self.refreshHistoryAfterRun(historyRequest: historyContext)
|
|
guard self.isCurrentSession(attempt.draft.session) else { return }
|
|
self.finishPendingRunAfterTerminalOkSendAck(response)
|
|
return
|
|
}
|
|
guard !self.finishPendingRunIfTerminalSendAck(response),
|
|
!reusedRunAlreadyFinal
|
|
else {
|
|
return
|
|
}
|
|
|
|
let historyContext = self.beginHistoryRequest(for: attempt.draft.session)
|
|
let refresh = await self.refreshHistoryAfterRun(historyRequest: historyContext)
|
|
guard self.isCurrentSession(attempt.draft.session) else { return }
|
|
let hasInFlightRunSnapshot = refresh.applied &&
|
|
refresh.runSnapshotApplied &&
|
|
refresh.hasInFlightRun
|
|
if hasInFlightRunSnapshot ||
|
|
!self.clearPendingRunIfAssistantMessagePresent(
|
|
runId: response.runId,
|
|
after: attempt.userMessageTimestamp)
|
|
{
|
|
self.armPendingRunOwner(
|
|
runId: response.runId,
|
|
sessionSnapshot: attempt.draft.session,
|
|
userMessageTimestamp: attempt.userMessageTimestamp)
|
|
}
|
|
}
|
|
|
|
private func adoptRemoteRunID(_ remoteRunId: String, replacing localRunId: String) -> Bool {
|
|
let pendingUserMessageID = self.pendingLocalUserEchoMessageIDsByRunID.removeValue(forKey: localRunId)
|
|
let localRunScope = self.runMessageScopesByRunID.removeValue(forKey: localRunId)
|
|
self.clearPendingRun(localRunId)
|
|
self.pendingRuns.insert(remoteRunId)
|
|
// 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: remoteRunId)
|
|
self.pendingLocalUserEchoMessageIDsByRunID[remoteRunId] = rekeyedUserEcho?.pendingMessageID
|
|
let remoteRunScope = rekeyedUserEcho?.scope ?? localRunScope ?? self.currentRunMessageScope()
|
|
self.runMessageScopesByRunID[remoteRunId] = remoteRunScope
|
|
self.rescopeProvisionalFinalMessages(runId: remoteRunId, scope: remoteRunScope)
|
|
let reusedRunAlreadyFinal = self.hasRecordedFinalMessage(runId: remoteRunId)
|
|
if reusedRunAlreadyFinal {
|
|
self.clearPendingRun(remoteRunId, hapticEvent: .runCompleted)
|
|
self.pendingToolCallsById = [:]
|
|
self.updateStreamingAssistantText(nil)
|
|
} else {
|
|
self.armPendingRunOwner(
|
|
runId: remoteRunId,
|
|
sessionSnapshot: remoteRunScope.session,
|
|
userMessageTimestamp: remoteRunScope.latestUserTurn?.timestamp)
|
|
}
|
|
return reusedRunAlreadyFinal
|
|
}
|
|
|
|
private func handleLiveSendFailure(_ error: Error, attempt: LiveSendAttempt) async {
|
|
guard self.isCurrentSession(attempt.draft.session) else { return }
|
|
if attempt.encodedAttachments.isEmpty, !(error is GatewayResponseError) {
|
|
self.runMessageScopesByRunID.removeValue(forKey: attempt.runId)
|
|
self.clearPendingRun(attempt.runId)
|
|
let deliveryIsAmbiguous = !(error is OpenClawChatTransportSendError)
|
|
let preserved = await self.preserveFailedLiveSend(
|
|
runId: attempt.runId,
|
|
text: attempt.draft.messageText,
|
|
thinking: self.effectiveThinkingLevelForSend(attempt.storedThinkingLevel),
|
|
messageID: attempt.userMessageID,
|
|
session: attempt.draft.session,
|
|
deliveryIsAmbiguous: deliveryIsAmbiguous)
|
|
if preserved {
|
|
self.applyTransportHealth(false)
|
|
let outcome = deliveryIsAmbiguous ? "delivery unconfirmed" : "queued after route change"
|
|
self.logDiagnostic(
|
|
"chat.ui send \(outcome) sessionKey=\(attempt.draft.session.key) "
|
|
+ "localRunId=\(attempt.runId) error=\(error.localizedDescription)")
|
|
return
|
|
}
|
|
guard self.isCurrentSession(attempt.draft.session) else { return }
|
|
// Refused persistence (queue full / broken store): restore the
|
|
// draft so the text is not lost with the failed bubble.
|
|
if self.input.isEmpty {
|
|
self.input = attempt.draft.messageText
|
|
}
|
|
}
|
|
self.restoreDraftAfterLiveSendFailure(attempt)
|
|
self.removePendingLocalUserEcho(for: attempt.runId)
|
|
self.runMessageScopesByRunID.removeValue(forKey: attempt.runId)
|
|
self.errorText = error.localizedDescription
|
|
self.clearPendingRun(attempt.runId, hapticEvent: .runFailed)
|
|
self.logDiagnostic(
|
|
"chat.ui send failed sessionKey=\(attempt.draft.session.key) "
|
|
+ "localRunId=\(attempt.runId) error=\(error.localizedDescription)")
|
|
chatSendingLogger.error("chat transport send failed \(error.localizedDescription, privacy: .public)")
|
|
}
|
|
|
|
private func restoreDraftAfterLiveSendFailure(_ attempt: LiveSendAttempt) {
|
|
if attempt.encodedAttachments.isEmpty, self.input.isEmpty {
|
|
self.input = attempt.draft.messageText
|
|
} else if !attempt.encodedAttachments.isEmpty {
|
|
if self.input.isEmpty {
|
|
self.input = attempt.draft.input
|
|
}
|
|
let currentAttachmentIDs = Set(self.attachments.map(\.id))
|
|
let removedDraftAttachments = attempt.draft.attachments.filter {
|
|
!currentAttachmentIDs.contains($0.id)
|
|
}
|
|
self.attachments.insert(contentsOf: removedDraftAttachments, at: 0)
|
|
}
|
|
}
|
|
}
|