refactor(native): deslop native apps (#163339)

Retire pre-July native client-ID and Talk credential paths, use shared transport owners, remove pure forwarding layers, and narrow Swift request completions to response frames.

The remaining native production diff removes 190 net lines. A sibling's independently landed health-decoder cleanup is excluded; its useful clean-JSON assertion is retained. Supported July 2026+ route, cancellation, pairing and lifecycle behavior stays intact.
This commit is contained in:
Peter Steinberger 2026-10-02 06:29:24 -05:00 • committed by GitHub
parent ee578bc732
commit cfcee64d26
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 108 additions and 288 deletions

View file

@ -18,7 +18,6 @@ import ai.openclaw.app.chat.ChatTranscriptAnchorState
import ai.openclaw.app.chat.ChatWidgetResource
import ai.openclaw.app.chat.GatewayDefaultAgentOwner
import ai.openclaw.app.chat.MessageSpeechState
import ai.openclaw.app.chat.OutgoingAttachment
import ai.openclaw.app.chat.SessionBranch
import ai.openclaw.app.chat.SessionDiffSnapshot
import ai.openclaw.app.chat.SessionForkResult
@ -1182,7 +1181,7 @@ class MainViewModel private constructor(
viewModelScope.launch {
try {
val accepted =
sendChatForOwnerAwaitAcceptance(
ensureRuntime().sendChatForOwnerAwaitAcceptance(
owner = pending.owner,
message = prompt,
thinking = thinking,
@ -2126,21 +2125,6 @@ class MainViewModel private constructor(
ensureRuntime().chat.skipQuestion(prompt)
}
internal suspend fun sendChatForOwnerAwaitAcceptance(
owner: ChatComposerOwner,
message: String,
thinking: String,
attachments: List<OutgoingAttachment>,
idempotencyKey: String,
): Boolean =
ensureRuntime().sendChatForOwnerAwaitAcceptance(
owner = owner,
message = message,
thinking = thinking,
attachments = attachments,
idempotencyKey = idempotencyKey,
)
/** Admission outlives the composing Activity; accepted payloads clear by owner and snapshot. */
internal fun beginChatComposerSend(
owner: ChatComposerOwner,
@ -2154,7 +2138,7 @@ class MainViewModel private constructor(
var accepted: Boolean? = null
try {
accepted =
sendChatForOwnerAwaitAcceptance(
ensureRuntime().sendChatForOwnerAwaitAcceptance(
owner = request.owner,
message = request.message,
thinking = thinking,

View file

@ -68,10 +68,7 @@ internal data class ClawSpacing(
val xxs: Dp = 8.dp,
val xs: Dp = 12.dp,
val sm: Dp = 16.dp,
val md: Dp = 20.dp,
val lg: Dp = 24.dp,
val xl: Dp = 32.dp,
val xxl: Dp = 40.dp,
// Touch target and visible shape are separate: `touchTarget` is the minimum
// hit area every control keeps, while `control`, `row`, `iconSlot`, and `icon`
// describe the smaller painted geometry that sits inside it.

View file

@ -12,6 +12,7 @@ OpenClaw iOS is the officially released iPhone app. It connects to an OpenClaw G
- UI and onboarding changes ship through normal app releases.
- Upgrade migrations support data written by releases shipped on or after July 1, 2026. Earlier UserDefaults gateway locations are no longer imported; reconnect from Gateway settings if needed. Push registrations without a relay profile are refreshed through normal registration.
- Connections use the current `openclaw-ios` client identity without pre-rename retries or undocumented legacy client-ID overrides. Talk credentials come from the Gateway's configuration; local provider keys whose writer retired before July are no longer read.
- Some node commands require foreground access because of iOS platform limits.
- Permissions, background behavior, and push delivery are documented below so release and support checks stay explicit.

View file

@ -153,43 +153,35 @@ final class ShareViewController: UIViewController {
defer {
Task { await gateway.disconnect() }
}
func connect(clientId: String) async throws {
try await gateway.connect(
url: url,
credentials: GatewayNodeSessionCredentials(
token: config.token,
password: config.password),
connectOptions: GatewayConnectOptions(
role: "node",
scopes: [],
caps: [],
commands: [],
permissions: [:],
clientId: clientId,
clientMode: "node",
clientDisplayName: "OpenClaw Share",
deviceIdentityProfile: .shareExtension,
includeDeviceIdentity: true,
allowStoredDeviceAuth: config.gatewayStableID != nil,
deviceAuthGatewayID: config.gatewayStableID),
sessionBox: nil,
onConnected: {},
onDisconnected: { _ in },
onInvoke: { req in
BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(
code: .invalidRequest,
message: "share extension does not support node invoke"))
})
}
do {
try await connect(clientId: "openclaw-ios")
} catch {
guard self.shouldRetryWithLegacyClientId(error) else { throw error }
try await connect(clientId: "moltbot-ios")
}
try await gateway.connect(
url: url,
credentials: GatewayNodeSessionCredentials(
token: config.token,
password: config.password),
connectOptions: GatewayConnectOptions(
role: "node",
scopes: [],
caps: [],
commands: [],
permissions: [:],
clientId: "openclaw-ios",
clientMode: "node",
clientDisplayName: "OpenClaw Share",
deviceIdentityProfile: .shareExtension,
includeDeviceIdentity: true,
allowStoredDeviceAuth: config.gatewayStableID != nil,
deviceAuthGatewayID: config.gatewayStableID),
sessionBox: nil,
onConnected: {},
onDisconnected: { _ in },
onInvoke: { req in
BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(
code: .invalidRequest,
message: "share extension does not support node invoke"))
})
struct AgentRequestPayload: Codable {
var message: String
@ -228,27 +220,6 @@ final class ShareViewController: UIViewController {
_ = try await gateway.request(method: "node.event", paramsJSON: nodeEventParams, timeoutSeconds: 25)
}
private func shouldRetryWithLegacyClientId(_ error: Error) -> Bool {
if let gatewayError = error as? GatewayResponseError {
let code = gatewayError.code.lowercased()
let message = gatewayError.message.lowercased()
let pathValue = (gatewayError.details["path"]?.value as? String)?.lowercased() ?? ""
let mentionsClientIdPath =
message.contains("/client/id") || message.contains("client id")
|| pathValue.contains("/client/id")
let isInvalidConnectParams =
(code.contains("invalid") && code.contains("connect"))
|| message.contains("invalid connect params")
if isInvalidConnectParams, mentionsClientIdPath {
return true
}
}
let text = error.localizedDescription.lowercased()
return text.contains("invalid connect params")
&& (text.contains("/client/id") || text.contains("client id"))
}
private func extractSharedContent() async -> ExtractedShareContent {
guard let items = self.extensionContext?.inputItems as? [NSExtensionItem] else {
return ExtractedShareContent(

View file

@ -458,14 +458,6 @@ struct IOSGatewayChatTransport: OpenClawChatGatewayTransport {
unread: unread)
}
func forkSession(parentKey: String) async throws -> String {
try await self.forkSession(parentKey: parentKey, fromLastCompleted: false)
}
func forkSession(parentKey: String, fromLastCompleted: Bool) async throws -> String {
try await self.forkSession(parentKey: parentKey, fromLastCompleted: fromLastCompleted, agentID: nil)
}
func forkSession(parentKey: String, fromLastCompleted: Bool, agentID: String?) async throws -> String {
let target = self.sessionTarget(for: parentKey, overrideAgentID: agentID)
let childAgentID = target.agentID ?? OpenClawChatSessionKey.agentID(from: target.sessionKey)
@ -634,10 +626,6 @@ struct IOSGatewayChatTransport: OpenClawChatGatewayTransport {
return error.message == "invalid chat.history params: at root: unexpected property 'inputRunIds'"
}
var supportsSlashCommandCatalog: Bool {
true
}
func waitForRunCompletion(
runId rawRunId: String,
timeoutMs: Int) async -> OpenClawChatRunObservation

View file

@ -60,13 +60,11 @@ extension GatewayConnectionController {
}
func makeConnectOptions(
stableID: String?,
deviceAuthGatewayID: String?,
allowStoredDeviceAuth: Bool = true) async -> GatewayConnectOptions
{
let defaults = UserDefaults.standard
let displayName = self.resolvedDisplayName(defaults: defaults)
let resolvedClientId = self.resolvedClientId(defaults: defaults, stableID: stableID)
let permissions = await self.currentPermissions()
let caps = self.currentCaps()
@ -76,27 +74,13 @@ extension GatewayConnectionController {
caps: caps,
commands: Self.commands(for: caps),
permissions: permissions,
clientId: resolvedClientId,
clientId: "openclaw-ios",
clientMode: "node",
clientDisplayName: displayName,
allowStoredDeviceAuth: allowStoredDeviceAuth,
deviceAuthGatewayID: GatewayStableIdentifier.exact(deviceAuthGatewayID))
}
private func resolvedClientId(defaults: UserDefaults, stableID: String?) -> String {
if let stableID,
let override = GatewaySettingsStore.loadGatewayClientIdOverride(stableID: stableID)
{
return override
}
let manualClientId = defaults.string(forKey: "gateway.manual.clientId")?
.trimmingCharacters(in: .whitespacesAndNewlines)
if manualClientId?.isEmpty == false {
return manualClientId!
}
return "openclaw-ios"
}
private func resolvedDisplayName(defaults: UserDefaults) -> String {
let key = "node.displayName"
let existingRaw = defaults.string(forKey: key)

View file

@ -656,7 +656,6 @@ final class GatewayConnectionController {
GatewaySettingsStore.deleteGatewayCredentials(instanceId: instanceID, stableID: stableID)
_ = GatewaySettingsStore.clearGatewayCustomHeaders(gatewayStableID: stableID)
_ = GatewayTLSStore.clearFingerprint(stableID: stableID)
GatewaySettingsStore.saveGatewayClientIdOverride(stableID: stableID, clientId: nil)
GatewaySettingsStore.saveGatewaySelectedAgentId(stableID: stableID, agentId: nil)
let shareRelayGatewayID = ShareGatewayRelaySettings.loadConfig()?.gatewayStableID
if GatewayStableIdentifier.matches(shareRelayGatewayID, stableID) {
@ -720,7 +719,6 @@ final class GatewayConnectionController {
var refreshedConfig = cfg
refreshedConfig.nodeOptions = await self.makeConnectOptions(
stableID: cfg.stableID,
deviceAuthGatewayID: cfg.nodeOptions.deviceAuthGatewayID,
allowStoredDeviceAuth: cfg.nodeOptions.allowStoredDeviceAuth)
appModel.applyGatewayConnectConfig(refreshedConfig, expectedGeneration: generation)
@ -1178,7 +1176,6 @@ extension GatewayConnectionController {
guard !Task.isCancelled, generation == appModel.gatewayConnectGeneration else { return }
}
let nodeOptions = await self.makeConnectOptions(
stableID: gatewayStableID,
deviceAuthGatewayID: GatewaySettingsStore.authenticationOwnerID(routeStableID: gatewayStableID),
allowStoredDeviceAuth: allowStoredDeviceAuth)
// Permission reads above can suspend long enough for a model-owned reconnect reset
@ -1295,7 +1292,6 @@ extension GatewayConnectionController {
instanceId: GatewaySettingsStore.currentInstanceID(),
gatewayStableID: stableID)
let nodeOptions = await self.makeConnectOptions(
stableID: stableID,
deviceAuthGatewayID: GatewaySettingsStore.authenticationOwnerID(routeStableID: stableID),
allowStoredDeviceAuth: !credentials.suppressStoredDeviceAuth)
return GatewayConnectConfig(

View file

@ -38,12 +38,10 @@ enum GatewaySettingsStore {
}
#endif
private static let nodeService = "ai.openclawfoundation.app.node"
private static let talkService = "ai.openclawfoundation.app.talk"
private static let instanceIdDefaultsKey = "node.instanceId"
private static let preferredGatewayStableIDDefaultsKey = "gateway.preferredStableID"
private static let lastDiscoveredGatewayStableIDDefaultsKey = "gateway.lastDiscoveredStableID"
private static let clientIdOverrideDefaultsPrefix = "gateway.clientIdOverride."
private static let selectedAgentDefaultsPrefix = "gateway.selectedAgentId."
private static let instanceIdAccount = "instanceId"
@ -52,7 +50,6 @@ enum GatewaySettingsStore {
private static let gatewayRegistryAccount = "gateway-registry"
private static let lastGatewayConnectionAccount = "lastConnection"
private static let gatewayCustomHeadersService = "ai.openclawfoundation.app.gateway.custom-headers"
private static let talkProviderApiKeyAccountPrefix = "provider.apiKey." // pragma: allowlist secret
struct GatewayRegistryEntry: Codable, Equatable, Identifiable, Sendable {
enum Kind: String, Codable, Sendable {
@ -421,14 +418,6 @@ enum GatewaySettingsStore {
var port: Int?
}
static func loadTalkProviderApiKey(provider: String) -> String? {
guard let providerId = self.normalizedTalkProviderID(provider) else { return nil }
let account = self.talkProviderApiKeyAccount(providerId: providerId)
return self.normalizedCredential(GenericPasswordKeychainStore.loadString(
service: self.talkService,
account: account))
}
static func loadGatewayRegistry() -> GatewayRegistry {
guard let json = GenericPasswordKeychainStore.loadString(
service: self.gatewayService,
@ -675,14 +664,6 @@ enum GatewaySettingsStore {
return deletedAll
}
static func loadGatewayClientIdOverride(stableID: String) -> String? {
self.loadGatewayDefault(prefix: self.clientIdOverrideDefaultsPrefix, stableID: stableID)
}
static func saveGatewayClientIdOverride(stableID: String, clientId: String?) {
self.saveGatewayDefault(clientId, prefix: self.clientIdOverrideDefaultsPrefix, stableID: stableID)
}
static func loadGatewaySelectedAgentId(stableID: String) -> String? {
self.loadGatewayDefault(prefix: self.selectedAgentDefaultsPrefix, stableID: stableID)
}
@ -857,15 +838,6 @@ enum GatewaySettingsStore {
account: "gateway-credential-metadata.\(instanceId)")
}
private static func talkProviderApiKeyAccount(providerId: String) -> String {
self.talkProviderApiKeyAccountPrefix + providerId
}
private static func normalizedTalkProviderID(_ provider: String) -> String? {
let trimmed = provider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return trimmed.isEmpty ? nil : trimmed
}
private static func ensureStableInstanceID() {
let defaults = UserDefaults.standard

View file

@ -311,7 +311,6 @@ final class NodeAppModel {
private struct NodeGatewayLoopState: Sendable {
var attempt = 0
var options: GatewayConnectOptions
var didFallbackClientID = false
}
private enum NodeGatewayLoopStep: Sendable {
@ -4387,12 +4386,10 @@ extension NodeAppModel {
let reconnectOptions = self.currentGatewayReconnectOptions(
stableID: stableID,
fallback: config.nodeOptions)
let effectiveClientId =
GatewaySettingsStore.loadGatewayClientIdOverride(stableID: stableID) ?? reconnectOptions.clientId
let talkPermissionUpgradeRequest = self.forceOperatorTalkPermissionUpgradeRequest
let deviceAuthGatewayID = reconnectOptions.deviceAuthGatewayID ?? stableID
let operatorOptions = self.makeOperatorConnectOptions(
clientId: effectiveClientId,
clientId: reconnectOptions.clientId,
displayName: reconnectOptions.clientDisplayName,
deviceAuthGatewayID: deviceAuthGatewayID,
includeAdminScope: self.shouldRequestOperatorAdminScope(
@ -4754,21 +4751,6 @@ extension NodeAppModel {
stableID: context.stableID)
else { return .stop }
if !state.didFallbackClientID,
let fallbackClientID = self.legacyClientIdFallback(
currentClientId: state.options.clientId,
error: error)
{
var nextState = state
nextState.didFallbackClientID = true
nextState.options.clientId = fallbackClientID
GatewaySettingsStore.saveGatewayClientIdOverride(
stableID: context.stableID,
clientId: fallbackClientID)
self.gatewayStatusText = "Gateway rejected client id. Retrying…"
return .retry(nextState)
}
var nextState = state
nextState.attempt += 1
let problem = self.applyNodeGatewayConnectionError(
@ -4959,16 +4941,6 @@ extension NodeAppModel {
deviceAuthGatewayID: deviceAuthGatewayID)
}
private func legacyClientIdFallback(currentClientId: String, error: Error) -> String? {
let normalizedClientId = currentClientId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard normalizedClientId == "openclaw-ios" else { return nil }
let message = error.localizedDescription.lowercased()
guard message.contains("invalid connect params"), message.contains("/client/id") else {
return nil
}
return "moltbot-ios"
}
func setOperatorConnected(_ connected: Bool) {
let changed = self.operatorConnected != connected
self.operatorConnected = connected

View file

@ -4319,14 +4319,13 @@ extension TalkModeManager {
}
self.defaultOutputFormat = parsed.defaultOutputFormat
let credentialProvider = routing.route.usesRealtime
? (routing.realtimeProvider ?? routing.activeProvider)
: routing.activeProvider
self.applyTalkConfigCredentials(
parsed: parsed,
activeProvider: routing.activeProvider,
gatewayOwnsCredentials: routing.route.gatewayOwnsCredentials,
credentialProvider: credentialProvider)
self.apiKey = routing.route.gatewayOwnsCredentials ? nil : Self.normalizedTalkApiKey(parsed.rawConfigApiKey)
if routing.route.gatewayOwnsCredentials {
let credentialProvider = routing.route.usesRealtime
? (routing.realtimeProvider ?? routing.activeProvider)
: routing.activeProvider
GatewayDiagnostics.log("talk provider '\(credentialProvider)' uses gateway-owned credentials")
}
self.applyTalkModeDescriptor(
routing: routing,
nativeModelId: routing.route == .localElevenLabs
@ -4348,28 +4347,6 @@ extension TalkModeManager {
}
}
private func applyTalkConfigCredentials(
parsed: TalkModeGatewayConfigState,
activeProvider: String,
gatewayOwnsCredentials: Bool,
credentialProvider: String)
{
let rawConfigApiKey = parsed.rawConfigApiKey
let configApiKey = Self.normalizedTalkApiKey(rawConfigApiKey)
let localApiKey = Self.normalizedTalkApiKey(
GatewaySettingsStore.loadTalkProviderApiKey(provider: activeProvider))
if rawConfigApiKey == Self.redactedConfigSentinel {
self.apiKey = localApiKey
GatewayDiagnostics.log("talk config apiKey redacted; using local override if present")
} else {
self.apiKey = localApiKey ?? configApiKey
}
if gatewayOwnsCredentials {
self.apiKey = nil
GatewayDiagnostics.log("talk provider '\(credentialProvider)' uses gateway-owned credentials")
}
}
private func applyTalkModeDescriptor(
routing: TalkModeResolvedRouting,
nativeModelId: String?,

View file

@ -289,7 +289,7 @@ private func waitUntil(
]) {
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let options = await controller.makeConnectOptions(stableID: nil, deviceAuthGatewayID: nil)
let options = await controller.makeConnectOptions(deviceAuthGatewayID: nil)
let caps = Set(options.caps)
#expect(!caps.contains(OpenClawCapability.canvas.rawValue))
@ -493,17 +493,17 @@ private func waitUntil(
}
@Test func `stored device token scope gap uses gateway scope compatibility`() {
#expect(!GatewayChannelActor._test_requestedScopesExceedStoredToken(
#expect(!GatewayChannelActor.requestedScopesExceedStoredToken(
role: "operator",
requestedScopes: ["operator.read", "operator.write", "operator.talk.secrets"],
storedToken: "stored-device-token",
storedScopes: ["operator.admin"]))
#expect(!GatewayChannelActor._test_requestedScopesExceedStoredToken(
#expect(!GatewayChannelActor.requestedScopesExceedStoredToken(
role: "operator",
requestedScopes: ["operator.read"],
storedToken: "stored-device-token",
storedScopes: []))
#expect(GatewayChannelActor._test_requestedScopesExceedStoredToken(
#expect(GatewayChannelActor.requestedScopesExceedStoredToken(
role: "operator",
requestedScopes: ["operator.admin"],
storedToken: "stored-device-token",

View file

@ -196,7 +196,7 @@ private func withLastGatewaySnapshot(_ body: () -> Void) {
service: service)["X-Owner"] == "exact")
}
@Test func `legacy gateway defaults cannot alias encoded owner keys`() {
@Test func `legacy selected agent defaults cannot alias encoded owner keys`() {
let exactOwner = "gateway-\(UUID().uuidString)"
let component = Data(exactOwner.utf8).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
@ -204,20 +204,14 @@ private func withLastGatewaySnapshot(_ body: () -> Void) {
.replacingOccurrences(of: "=", with: "")
let collidingLegacyOwner = "v2.\(component)"
defer {
GatewaySettingsStore.saveGatewayClientIdOverride(stableID: exactOwner, clientId: nil)
GatewaySettingsStore.saveGatewayClientIdOverride(stableID: collidingLegacyOwner, clientId: nil)
GatewaySettingsStore.saveGatewaySelectedAgentId(stableID: exactOwner, agentId: nil)
GatewaySettingsStore.saveGatewaySelectedAgentId(stableID: collidingLegacyOwner, agentId: nil)
}
GatewaySettingsStore.saveGatewayClientIdOverride(stableID: exactOwner, clientId: "exact-client")
GatewaySettingsStore.saveGatewaySelectedAgentId(stableID: exactOwner, agentId: "exact-agent")
#expect(GatewaySettingsStore.loadGatewayClientIdOverride(stableID: collidingLegacyOwner) == nil)
#expect(GatewaySettingsStore.loadGatewaySelectedAgentId(stableID: collidingLegacyOwner) == nil)
GatewaySettingsStore.saveGatewayClientIdOverride(stableID: collidingLegacyOwner, clientId: nil)
GatewaySettingsStore.saveGatewaySelectedAgentId(stableID: collidingLegacyOwner, agentId: nil)
#expect(GatewaySettingsStore.loadGatewayClientIdOverride(stableID: exactOwner) == "exact-client")
#expect(GatewaySettingsStore.loadGatewaySelectedAgentId(stableID: exactOwner) == "exact-agent")
}

View file

@ -105,22 +105,4 @@ extension MacGatewayChatTransport {
request,
ifCurrentServerLease: serverLease)
}
func forkSession(parentKey: String) async throws -> String {
try await self.forkSession(parentKey: parentKey, fromLastCompleted: false)
}
func forkSession(parentKey: String, fromLastCompleted: Bool) async throws -> String {
try await self.forkSession(parentKey: parentKey, fromLastCompleted: fromLastCompleted, agentID: nil)
}
func forkSession(parentKey: String, fromLastCompleted: Bool, agentID: String?) async throws -> String {
let target = self.sessionTarget(for: parentKey, overrideAgentID: agentID)
let request = OpenClawChatGatewayRequests.forkSession(
parentSessionKey: target.sessionKey,
agentID: target.agentID,
fromLastCompleted: fromLastCompleted)
let data = try await self.requestChatSessionAction(request)
return try JSONDecoder().decode(OpenClawChatCreateSessionResponse.self, from: data).key
}
}

View file

@ -652,10 +652,16 @@ struct MacGatewayChatTransport: OpenClawChatGatewayTransport {
throw OpenClawChatTransportSendError.notDispatched
}
try await self.requireCurrentOutboxGateway()
return try await MacChatMessageSpeechClient.synthesize(
text: text,
serverLease: serverLease,
connection: self.connection)
let encoded = try JSONEncoder().encode(TtsSpeakParams(text: text))
guard let params = try JSONSerialization.jsonObject(with: encoded) as? [String: Any] else {
throw MacChatMessageSpeechError.invalidRequest
}
let responseData = try await self.connection.request(
method: "tts.speak",
params: params.mapValues(AnyCodable.init),
timeoutMs: 60000,
ifCurrentServerLease: serverLease)
return try OpenClawChatGatewayPayloadCodec.decodeSpeechClip(responseData)
}
func loadSourceContext() async -> OpenClawChatSourceContext? {
@ -687,10 +693,6 @@ struct MacGatewayChatTransport: OpenClawChatGatewayTransport {
ifCurrentServerLease: serverLease)
}
var supportsSlashCommandCatalog: Bool {
true
}
func createSession(
key: String,
label: String?,
@ -845,27 +847,6 @@ private enum MacChatMessageSpeechError: LocalizedError {
}
}
private enum MacChatMessageSpeechClient {
private static let requestTimeoutMs: Double = 60000
static func synthesize(
text: String,
serverLease: GatewayConnection.ServerLease,
connection: GatewayConnection) async throws -> OpenClawChatSpeechClip
{
let encoded = try JSONEncoder().encode(TtsSpeakParams(text: text))
guard let params = try JSONSerialization.jsonObject(with: encoded) as? [String: Any] else {
throw MacChatMessageSpeechError.invalidRequest
}
let responseData = try await connection.request(
method: "tts.speak",
params: params.mapValues(AnyCodable.init),
timeoutMs: self.requestTimeoutMs,
ifCurrentServerLease: serverLease)
return try OpenClawChatGatewayPayloadCodec.decodeSpeechClip(responseData)
}
}
@MainActor
private struct MacChatSurface: View {
let windowCommands: OpenClawChatWindowCommands

View file

@ -13,6 +13,7 @@ struct HealthDecodeTests {
let snap = decodeHealthSnapshot(from: data)
#expect(snap?.channels["whatsapp"]?.linked == true)
#expect(snap?.channels["telegram"]?.probe?.elapsedMs == 800)
#expect(snap?.sessions.count == 1)
}

View file

@ -1915,10 +1915,10 @@ struct OnboardingAISetupTests {
}
await startGate.waitUntilStarted()
model.cancelProviderAuth()
let cancellation = try #require(model.cancelProviderAuth())
try #require(model.activeAuthOption != nil)
do {
_ = try await waitForAISetupRequests(harness.recorder, count: 3)
await cancellation.value
await startGate.release()
try await TestWait.observed("settled provider auth") { model.activeAuthOption == nil }
try #require(model.activeAuthOption == nil)

View file

@ -12,6 +12,10 @@ public protocol OpenClawChatGatewayTransport: OpenClawChatTransport {
}
extension OpenClawChatGatewayTransport {
public var supportsSlashCommandCatalog: Bool {
true
}
public func reactionsRouteLease(
routeID: UUID,
access: OpenClawChatReactionAccess,
@ -162,6 +166,24 @@ extension OpenClawChatGatewayTransport {
return try JSONDecoder().decode(OpenClawChatRewindResponse.self, from: data)
}
public func forkSession(parentKey: String) async throws -> String {
try await self.forkSession(parentKey: parentKey, fromLastCompleted: false)
}
public func forkSession(parentKey: String, fromLastCompleted: Bool) async throws -> String {
try await self.forkSession(parentKey: parentKey, fromLastCompleted: fromLastCompleted, agentID: nil)
}
public func forkSession(parentKey: String, fromLastCompleted: Bool, agentID: String?) async throws -> String {
let target = self.sessionTarget(for: parentKey, overrideAgentID: agentID)
let request = OpenClawChatGatewayRequests.forkSession(
parentSessionKey: target.sessionKey,
agentID: target.agentID,
fromLastCompleted: fromLastCompleted)
let data = try await self.requestChatSessionAction(request)
return try JSONDecoder().decode(OpenClawChatCreateSessionResponse.self, from: data).key
}
public func forkSessionAtMessage(
sessionKey: String,
entryId: String) async throws -> OpenClawChatForkAtMessageResponse

View file

@ -698,20 +698,7 @@ extension GatewayChannelActor {
suppressedDeviceTokenRetry: suppressedDeviceTokenRetry)
}
nonisolated static func _test_requestedScopesExceedStoredToken(
role: String,
requestedScopes: [String],
storedToken: String?,
storedScopes: [String]) -> Bool
{
self.requestedScopesExceedStoredToken(
role: role,
requestedScopes: requestedScopes,
storedToken: storedToken,
storedScopes: storedScopes)
}
private nonisolated static func requestedScopesExceedStoredToken(
nonisolated static func requestedScopesExceedStoredToken(
role: String,
requestedScopes: [String],
storedToken: String?,
@ -1101,7 +1088,7 @@ extension GatewayChannelActor {
}
switch frame {
case let .res(res):
self.finishRequest(id: res.id, result: .success(.res(res)))
self.finishRequest(id: res.id, result: .success(res))
case let .event(evt):
if evt.event == "connect.challenge" { return }
if let seq = evt.seq {
@ -1428,11 +1415,11 @@ extension GatewayChannelActor {
let effectiveTimeout = Self.resolveRequestTimeoutMs(timeoutMs, defaultMs: self.defaultRequestTimeoutMs)
let payload = try self.encodeRequest(method: method, params: params, kind: "request")
let cancellationGate = GatewayRequestCancellationGate()
let response: GatewayFrame
let response: ResponseFrame
do {
response = try await withTaskCancellationHandler {
try Task.checkCancellation()
return try await withCheckedThrowingContinuation { (cont: CheckedContinuation<GatewayFrame, Error>) in
return try await withCheckedThrowingContinuation { (cont: CheckedContinuation<ResponseFrame, Error>) in
guard !cancellationGate.isCancelled else {
cont.resume(throwing: CancellationError())
return
@ -1494,16 +1481,13 @@ extension GatewayChannelActor {
}
#endif
try Task.checkCancellation()
guard case let .res(res) = response else {
throw NSError(domain: "Gateway", code: 2, userInfo: [NSLocalizedDescriptionKey: "unexpected frame"])
}
if res.ok == false {
let code = res.error?.code
let msg = res.error?.message
let details = gatewayErrorDetails(res.error)
if response.ok == false {
let code = response.error?.code
let msg = response.error?.message
let details = gatewayErrorDetails(response.error)
throw GatewayResponseError(method: method, code: code, message: msg, details: details)
}
if let payload = res.payload {
if let payload = response.payload {
// Encode back to JSON with Swift's encoder to preserve types and avoid ObjC bridging exceptions.
return try self.encoder.encode(payload)
}
@ -1621,7 +1605,7 @@ extension GatewayChannelActor {
}
}
private func finishRequest(id: String, result: Result<GatewayFrame, Error>) {
private func finishRequest(id: String, result: Result<ResponseFrame, Error>) {
guard let request = self.pending.removeValue(forKey: id) else { return }
// A deadline belongs to its pending request, including after caller cancellation or disconnect.
request.timeoutTask?.cancel()

View file

@ -45,7 +45,7 @@ final class GatewayRequestCancellationGate: @unchecked Sendable {
extension GatewayChannelActor {
struct PendingRequest {
let continuation: CheckedContinuation<GatewayFrame, Error>
let continuation: CheckedContinuation<ResponseFrame, Error>
var timeoutTask: Task<Void, Never>?
let transportLifetime = WebSocketRequestLifetime()
}

View file

@ -94,18 +94,32 @@ struct ChatGatewayTransportTests {
if request.method == "sessions.rewind" {
return Data(#"{"editorText":"restored draft"}"#.utf8)
}
if request.method == "sessions.create" {
return Data(#"{"key":"agent:reviewer:child"}"#.utf8)
}
throw Failure.retiredRoute
})
let response = try await transport.rewindSession(sessionKey: "global", entryId: "message-1")
#expect(response.editorText == "restored draft")
#expect(try await transport.forkSession(parentKey: "global") == "agent:reviewer:child")
#expect(try await transport.forkSession(
parentKey: "global", fromLastCompleted: true, agentID: "other") == "agent:reviewer:child")
await #expect(throws: Failure.retiredRoute) {
try await transport.switchSessionBranch(sessionKey: "global", agentID: "other", leafEntryId: "leaf-1")
}
let requests = await recorder.requests
#expect(requests.map(\.method) == ["sessions.rewind", "sessions.branches.switch"])
#expect(requests.map(\.method) == [
"sessions.rewind", "sessions.create", "sessions.create", "sessions.branches.switch",
])
#expect(requests[0].params["agentId"]?.value as? String == "reviewer")
#expect(requests[1].params["agentId"]?.value as? String == "other")
#expect(requests[1].params["agentId"]?.value as? String == "reviewer")
#expect(requests[1].params["parentSessionKey"]?.value as? String == "global")
#expect(requests[1].params["fork"]?.value as? Bool == true)
#expect(requests[1].params["forkFrom"] == nil)
#expect(requests[2].params["agentId"]?.value as? String == "other")
#expect(requests[2].params["forkFrom"]?.value as? String == "last-completed")
#expect(requests[3].params["agentId"]?.value as? String == "other")
}
private enum Failure: Error { case unexpectedRequest, retiredRoute }