Files
openclaw/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift
Peter Steinberger 9b64c861e5 fix(chat): prevent reasoning races after model changes (#106534)
* fix(chat): serialize session setting changes

* fix(chat): bind settings mutations to gateway routes

* fix(chat): align settings lanes with shared requests

* fix(ios): preserve injected session mutations

* fix(chat): preserve authoritative settings refreshes

* fix(chat): reconcile authoritative settings state

* fix(ci): satisfy session settings quality gates

* fix(android): regenerate native string resources

* fix(ci): update session settings export baseline

* fix(ci): reconcile dead export baseline

* fix(android): resume outbox after settings lane

* chore(i18n): refresh native source inventory

* chore(i18n): refresh rebased native inventory

* chore(i18n): sync control UI locales

* chore: keep release notes in pull request

* style(macos): fix settings comment format

* fix(ai): keep Google thinking type internal
2026-07-13 14:27:54 -07:00

486 lines
19 KiB
Swift

import Foundation
import OpenClawChatUI
import OpenClawKit
import OpenClawProtocol
import OSLog
struct IOSGatewayChatTransport: OpenClawChatTransport {
static let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "ios.chat.transport")
private let gateway: GatewayNodeSession
private let globalAgentId: String?
private let outboxGatewayID: String?
private let sessionMutationRequest: (@Sendable (OpenClawChatGatewayRequest) async throws -> Data)?
var outboxRequiresSessionRoutingContract: Bool {
true
}
init(
gateway: GatewayNodeSession,
globalAgentId: String? = nil,
outboxGatewayID: String? = nil,
sessionMutationRequest: (@Sendable (OpenClawChatGatewayRequest) async throws -> Data)? = nil)
{
self.gateway = gateway
let normalized = globalAgentId?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
self.globalAgentId = normalized?.isEmpty == false ? normalized : nil
let normalizedGatewayID = outboxGatewayID?.trimmingCharacters(in: .whitespacesAndNewlines)
self.outboxGatewayID = normalizedGatewayID?.isEmpty == false ? normalizedGatewayID : nil
self.sessionMutationRequest = sessionMutationRequest
}
func acquireOutboxRouteLease() async -> OpenClawChatTransportRouteLeaseResult {
guard let outboxGatewayID,
let route = await gateway.currentRoute(ifGatewayID: outboxGatewayID)
else { return .unavailable(reason: nil) }
guard let supportsRoutingContract = await gateway.supportsServerCapability(
.chatSendRoutingContract,
ifCurrentRoute: route)
else { return .unavailable(reason: nil) }
guard supportsRoutingContract else {
return .unavailable(reason: OpenClawChatTransportUpgradeMessage.routingContract)
}
let transport = self
guard let routingContract = try? await transport.sessionRoutingContract(ifCurrentRoute: route)
else { return .unavailable(reason: nil) }
return .available(OpenClawChatTransportRouteLease(
sendTargetedMessage: { sessionKey, agentID, message, thinking, idempotencyKey, attachments in
try await transport.sendMessage(
sessionKey: sessionKey,
agentID: agentID,
expectedSessionRoutingContract: routingContract,
message: message,
thinking: thinking,
idempotencyKey: idempotencyKey,
attachments: attachments,
ifCurrentRoute: route,
distinguishPreDispatchRouteChange: true)
},
requestTargetedHistory: { sessionKey, agentID in
try await transport.requestHistory(
sessionKey: sessionKey,
agentID: agentID,
ifCurrentRoute: route)
},
sessionRoutingContract: routingContract))
}
func acquireSessionSettingsRouteLease() async -> OpenClawChatSessionSettingsRouteLease? {
let route: GatewayNodeSessionRoute? = if let outboxGatewayID {
await self.gateway.currentRoute(ifGatewayID: outboxGatewayID)
} else {
await self.gateway.currentRoute()
}
guard let route else { return nil }
let transport = self
return OpenClawChatSessionSettingsRouteLease { sessionKey, agentID, patch in
try await transport.patchSessionSettings(
sessionKey: sessionKey,
agentID: agentID,
patch: patch,
ifCurrentRoute: route)
}
}
private func sessionRoutingContract(
ifCurrentRoute route: GatewayNodeSessionRoute) async throws -> String
{
let data = try await gateway.request(
OpenClawChatGatewayRequests.agentsList(),
ifCurrentRoute: route)
return try OpenClawChatGatewayPayloadCodec.decodeSessionRoutingIdentity(data).contract
}
typealias SessionTarget = OpenClawChatSessionTarget
static func sessionTarget(
for rawSessionKey: String,
selectedAgentID: String?,
overrideAgentID: String? = nil) -> SessionTarget
{
OpenClawChatSessionTarget.resolve(
rawSessionKey,
selectedAgentID: selectedAgentID,
overrideAgentID: overrideAgentID,
policy: .scopeBareKeysToSelectedAgent)
}
private func sessionTarget(
for sessionKey: String,
overrideAgentID: String? = nil) -> SessionTarget
{
Self.sessionTarget(
for: sessionKey,
selectedAgentID: self.globalAgentId,
overrideAgentID: overrideAgentID)
}
private func requestSessionMutation(_ request: OpenClawChatGatewayRequest) async throws -> Data {
if let sessionMutationRequest {
return try await sessionMutationRequest(request)
}
return try await self.gateway.request(request)
}
func createSession(
key: String,
label: String?,
parentSessionKey: String?,
worktree: Bool?) async throws -> OpenClawChatCreateSessionResponse
{
let target = self.sessionTarget(for: key)
let parentTarget = parentSessionKey.map { self.sessionTarget(for: $0) }
let request = OpenClawChatGatewayRequests.createSession(
key: target.sessionKey,
agentID: target.agentID ?? parentTarget?.agentID,
label: label,
parentSessionKey: parentTarget?.sessionKey,
worktree: worktree)
let res = try await gateway.request(request)
return try JSONDecoder().decode(OpenClawChatCreateSessionResponse.self, from: res)
}
func abortRun(sessionKey: String, runId: String) async throws {
let target = self.sessionTarget(for: sessionKey)
let request = OpenClawChatGatewayRequests.abortRun(
sessionKey: target.sessionKey,
agentID: target.agentID,
runID: runId)
_ = try await self.gateway.request(request)
}
func listSessions(
limit: Int?,
search: String?,
archived: Bool) async throws -> OpenClawChatSessionsListResponse
{
let request = OpenClawChatGatewayRequests.sessionsList(
limit: limit,
search: search,
archived: archived)
let res = try await gateway.request(request)
return try JSONDecoder().decode(OpenClawChatSessionsListResponse.self, from: res)
}
func listModels() async throws -> [OpenClawChatModelChoice] {
let response = try await gateway.request(OpenClawChatGatewayRequests.modelsList())
return try OpenClawChatGatewayPayloadCodec.decodeModelChoices(response)
}
func setSessionModel(sessionKey: String, model: String?) async throws {
_ = try await self.patchSessionModel(sessionKey: sessionKey, agentID: nil, model: model)
}
func patchSessionModel(
sessionKey: String,
agentID: String?,
model: String?) async throws -> OpenClawChatModelPatchResult?
{
try await self.patchSessionSettings(
sessionKey: sessionKey,
agentID: agentID,
patch: OpenClawChatSessionSettingsPatch(model: .some(model)))
}
func patchSessionSettings(
sessionKey: String,
agentID: String?,
patch: OpenClawChatSessionSettingsPatch) async throws -> OpenClawChatModelPatchResult?
{
try await self.patchSessionSettings(
sessionKey: sessionKey,
agentID: agentID,
patch: patch,
ifCurrentRoute: nil)
}
private func patchSessionSettings(
sessionKey: String,
agentID: String?,
patch: OpenClawChatSessionSettingsPatch,
ifCurrentRoute expectedRoute: GatewayNodeSessionRoute?) async throws -> OpenClawChatModelPatchResult?
{
let target = self.sessionTarget(for: sessionKey, overrideAgentID: agentID)
let request = OpenClawChatGatewayRequests.patchSessionSettings(
sessionKey: target.sessionKey,
agentID: target.agentID,
model: patch.model,
thinkingLevel: patch.thinkingLevel)
let response = if let expectedRoute {
try await self.gateway.request(
request,
ifCurrentRoute: expectedRoute,
distinguishPreDispatchRouteChange: true)
} else {
try await self.requestSessionMutation(request)
}
return try Self.decodeModelPatchResult(response)
}
static func decodeModelPatchResult(_ data: Data) throws -> OpenClawChatModelPatchResult {
try JSONDecoder().decode(OpenClawChatModelPatchResult.self, from: data)
}
func setSessionThinking(sessionKey: String, thinkingLevel: String) async throws {
let target = self.sessionTarget(for: sessionKey)
_ = try await self.patchSessionSettings(
sessionKey: target.sessionKey,
agentID: target.agentID,
patch: OpenClawChatSessionSettingsPatch(thinkingLevel: .some(thinkingLevel)))
}
func patchSession(
key: String,
label: String?? = nil,
category: String?? = nil,
pinned: Bool? = nil,
archived: Bool? = nil,
unread: Bool? = nil) async throws
{
let target = self.sessionTarget(for: key)
let request = OpenClawChatGatewayRequests.patchSession(
sessionKey: target.sessionKey,
agentID: target.agentID,
label: label,
category: category,
pinned: pinned,
archived: archived,
unread: unread)
_ = try await self.requestSessionMutation(request)
}
func deleteSession(key: String) async throws {
let target = self.sessionTarget(for: key)
let request = OpenClawChatGatewayRequests.deleteSession(
sessionKey: target.sessionKey,
agentID: target.agentID)
_ = try await self.requestSessionMutation(request)
}
func forkSession(parentKey: String) async throws -> String {
let target = self.sessionTarget(for: parentKey)
let childAgentID = target.agentID ?? OpenClawChatSessionKey.agentID(from: target.sessionKey)
let request = OpenClawChatGatewayRequests.forkSession(
parentSessionKey: target.sessionKey,
agentID: childAgentID)
let response = try await requestSessionMutation(request)
return try JSONDecoder().decode(OpenClawChatCreateSessionResponse.self, from: response).key
}
func setActiveSessionKey(_ sessionKey: String) async throws {
let target = self.sessionTarget(for: sessionKey)
let request = OpenClawChatGatewayRequests.subscribeSessionMessages(
sessionKey: target.sessionKey,
agentID: target.agentID)
_ = try await self.gateway.request(request)
}
func resetSession(sessionKey: String) async throws {
let target = self.sessionTarget(for: sessionKey)
let request = OpenClawChatGatewayRequests.resetSession(
sessionKey: target.sessionKey,
agentID: target.agentID)
_ = try await self.gateway.request(request)
}
func compactSession(sessionKey: String) async throws {
let target = self.sessionTarget(for: sessionKey)
let request = OpenClawChatGatewayRequests.compactSession(
sessionKey: target.sessionKey,
agentID: target.agentID)
let response = try await gateway.request(request)
try OpenClawSessionsCompactResponse.requireSuccess(from: response)
}
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
try await self.requestHistory(sessionKey: sessionKey, agentID: nil, ifCurrentRoute: nil)
}
func requestHistory(
sessionKey: String,
agentID: String? = nil,
ifCurrentRoute expectedRoute: GatewayNodeSessionRoute?) async throws -> OpenClawChatHistoryPayload
{
let target = self.sessionTarget(for: sessionKey, overrideAgentID: agentID)
let request = OpenClawChatGatewayRequests.history(
sessionKey: target.sessionKey,
agentID: target.agentID)
let res = try await gateway.request(
request,
ifCurrentRoute: expectedRoute)
return try JSONDecoder().decode(OpenClawChatHistoryPayload.self, from: res)
}
var supportsSlashCommandCatalog: Bool {
true
}
func listCommands(sessionKey: String) async throws -> [OpenClawChatCommandChoice] {
let request = OpenClawChatGatewayRequests.commandsList(
sessionKey: sessionKey,
fallbackAgentID: self.globalAgentId)
let res = try await gateway.request(request)
let decoded = try JSONDecoder().decode(CommandsListResult.self, from: res)
return decoded.commands.map(OpenClawChatGatewayPayloadCodec.commandChoice)
}
func sendMessage(
sessionKey: String,
message: String,
thinking: String,
idempotencyKey: String,
attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
{
try await self.sendMessage(
sessionKey: sessionKey,
agentID: nil,
message: message,
thinking: thinking,
idempotencyKey: idempotencyKey,
attachments: attachments,
ifCurrentRoute: nil)
}
func sendMessage(
sessionKey: String,
agentID: String?,
expectedSessionRoutingContract: String?,
message: String,
thinking: String,
idempotencyKey: String,
attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
{
let route: GatewayNodeSessionRoute? = if let outboxGatewayID {
await self.gateway.currentRoute(ifGatewayID: outboxGatewayID)
} else {
await self.gateway.currentRoute()
}
guard let route,
let supportsRoutingContract = await gateway.supportsServerCapability(
.chatSendRoutingContract,
ifCurrentRoute: route)
else { throw OpenClawChatTransportSendError.notDispatched }
// Durable replay requires the atomic server guard and is blocked in
// acquireOutboxRouteLease. Keep ordinary live chat compatible with
// older gateways by retaining the captured route but omitting the
// unsupported request field.
let guardedContract = OpenClawChatSessionRoutingContract.expectedValue(
expectedSessionRoutingContract,
serverSupportsGuard: supportsRoutingContract)
return try await self.sendMessage(
sessionKey: sessionKey,
agentID: agentID,
expectedSessionRoutingContract: guardedContract,
message: message,
thinking: thinking,
idempotencyKey: idempotencyKey,
attachments: attachments,
ifCurrentRoute: route,
distinguishPreDispatchRouteChange: true)
}
func sendMessage(
sessionKey: String,
agentID: String? = nil,
expectedSessionRoutingContract: String? = nil,
message: String,
thinking: String,
idempotencyKey: String,
attachments: [OpenClawChatAttachmentPayload],
ifCurrentRoute expectedRoute: GatewayNodeSessionRoute?,
distinguishPreDispatchRouteChange: Bool = false) async throws -> OpenClawChatSendResponse
{
let target = self.sessionTarget(for: sessionKey, overrideAgentID: agentID)
let startLogMessage =
"chat.send start sessionKey=\(target.sessionKey) "
+ "len=\(message.count) attachments=\(attachments.count)"
Self.logger.info(
"\(startLogMessage, privacy: .public)")
GatewayDiagnostics.log(startLogMessage)
let request = OpenClawChatGatewayRequests.sendMessage(
sessionKey: target.sessionKey,
agentID: target.agentID,
expectedSessionRoutingContract: expectedSessionRoutingContract,
message: message,
thinking: thinking,
idempotencyKey: idempotencyKey,
attachments: attachments)
do {
let res = try await gateway.request(
request,
ifCurrentRoute: expectedRoute,
distinguishPreDispatchRouteChange: distinguishPreDispatchRouteChange)
let decoded = try JSONDecoder().decode(OpenClawChatSendResponse.self, from: res)
Self.logger.info("chat.send ok runId=\(decoded.runId, privacy: .public)")
GatewayDiagnostics.log("chat.send ok runId=\(decoded.runId) status=\(decoded.status)")
return decoded
} catch is GatewayNodeSessionRequestError {
Self.logger.info("chat.send skipped because the captured route changed before dispatch")
GatewayDiagnostics.log("chat.send skipped before dispatch: route changed")
throw OpenClawChatTransportSendError.notDispatched
} catch {
Self.logger.error("chat.send failed \(error.localizedDescription, privacy: .public)")
GatewayDiagnostics.log("chat.send failed error=\(error.localizedDescription)")
throw error
}
}
func waitForRunCompletion(
runId rawRunId: String,
timeoutMs: Int) async -> OpenClawChatRunObservation
{
let route = await self.gateway.currentRoute()
return await self.waitForRunCompletion(
runId: rawRunId,
timeoutMs: timeoutMs,
ifCurrentRoute: route)
}
func waitForRunCompletion(
runId rawRunId: String,
timeoutMs: Int,
ifCurrentRoute expectedRoute: GatewayNodeSessionRoute?) async -> OpenClawChatRunObservation
{
let runId = rawRunId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !runId.isEmpty, let expectedRoute else { return .unavailable }
do {
let request = OpenClawChatGatewayRequests.agentWait(runID: runId, timeoutMs: timeoutMs)
GatewayDiagnostics.log("agent.wait start runId=\(runId)")
let res = try await gateway.request(
request,
ifCurrentRoute: expectedRoute)
let observation = try OpenClawChatGatewayPayloadCodec.decodeAgentWaitObservation(res)
GatewayDiagnostics.log("agent.wait completed runId=\(runId) observation=\(observation)")
return observation
} catch {
Self.logger.warning("agent.wait failed \(error.localizedDescription, privacy: .public)")
GatewayDiagnostics.log("agent.wait failed runId=\(runId) error=\(error.localizedDescription)")
return .unavailable
}
}
func requestHealth(timeoutMs: Int) async throws -> Bool {
let res = try await gateway.request(OpenClawChatGatewayRequests.health(timeoutMs: timeoutMs))
return (try? JSONDecoder().decode(OpenClawGatewayHealthOK.self, from: res))?.ok ?? true
}
func events() -> AsyncStream<OpenClawChatTransportEvent> {
AsyncStream { continuation in
let task = Task {
let stream = await self.gateway.subscribeServerEvents()
for await evt in stream {
if Task.isCancelled { return }
if let mapped = OpenClawChatGatewayPayloadCodec.event(from: evt) {
continuation.yield(mapped)
}
}
}
continuation.onTermination = { @Sendable _ in
task.cancel()
}
}
}
}