diff --git a/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift b/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift index 784ac4693a8a..f6fd38442d3b 100644 --- a/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift +++ b/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift @@ -375,11 +375,7 @@ struct IOSGatewayChatTransport: OpenClawChatGatewayTransport { patch: OpenClawChatSessionSettingsPatch, ifCurrentRoute expectedRoute: GatewayNodeSessionRoute?) async throws -> OpenClawChatModelPatchResult? { - let requiresSettingsContract = patch.expectedSessionID != nil || - patch.permissionMode != nil || patch.toolOverrides != nil - let requiresSettingsCAS = patch.expectedPermissionMode != nil || - patch.expectedToolOverrides != nil || patch.permissionMode != nil || patch.toolOverrides != nil - let fallbackRoute: GatewayNodeSessionRoute? = if requiresSettingsContract, expectedRoute == nil { + let fallbackRoute: GatewayNodeSessionRoute? = if patch.requiresSessionSettingsContract, expectedRoute == nil { await self.currentSessionMutationRoute() } else { nil @@ -390,25 +386,11 @@ struct IOSGatewayChatTransport: OpenClawChatGatewayTransport { } else { (settingsContract: false, settingsCAS: false) } - guard !requiresSettingsContract || settingsSupport.settingsContract else { - throw OpenClawChatTransportSendError.notDispatched - } - guard !requiresSettingsCAS || settingsSupport.settingsCAS else { - throw OpenClawChatTransportSendError.notDispatched - } let target = self.sessionTarget(for: sessionKey, overrideAgentID: agentID) - let request = OpenClawChatGatewayRequests.patchSessionSettings( + let request = try OpenClawChatGatewayRequests.patchSessionSettings( sessionKey: target.sessionKey, agentID: target.agentID, - expectedSessionID: patch.expectedSessionID, - expectedPermissionMode: patch.expectedPermissionMode, - expectedToolOverrides: patch.expectedToolOverrides, - model: patch.model, - thinkingLevel: patch.thinkingLevel, - fastMode: patch.fastMode, - verboseLevel: patch.verboseLevel, - permissionMode: patch.permissionMode, - toolOverrides: patch.toolOverrides, + patch: patch, supportsSessionSettingsContract: settingsSupport.settingsContract, supportsSessionSettingsCAS: settingsSupport.settingsCAS) let response = if let settingsRoute { diff --git a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift index 7fa36022040e..c13861c37c34 100644 --- a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift +++ b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift @@ -476,34 +476,41 @@ struct MacGatewayChatTransport: OpenClawChatGatewayTransport { patch: OpenClawChatSessionSettingsPatch, serverLease: GatewayConnection.ServerLease?) async throws -> OpenClawChatModelPatchResult? { + var settingsLease = serverLease + if settingsLease == nil, patch.requiresSessionSettingsContract || patch.requiresSessionSettingsCAS { + guard let capturedLease = await self.connection.captureServerLease() else { + throw OpenClawChatTransportSendError.notDispatched + } + settingsLease = capturedLease + } + let supportsSettingsContract = if let settingsLease { + await self.connection.supportsServerCapability( + .sessionSettingsContract, ifCurrentServerLease: settingsLease) == true + } else { + false + } + let supportsSettingsCAS = if let settingsLease { + await self.connection.supportsServerCapability( + .sessionSettingsCAS, ifCurrentServerLease: settingsLease) == true + } else { + false + } let target = self.sessionTarget(for: sessionKey, overrideAgentID: agentID) - let request = Self.sessionSettingsRequest( + let request = try OpenClawChatGatewayRequests.patchSessionSettings( sessionKey: target.sessionKey, agentID: target.agentID, - patch: patch) - let data: Data = if let serverLease { + patch: patch, + supportsSessionSettingsContract: supportsSettingsContract, + supportsSessionSettingsCAS: supportsSettingsCAS) + let data: Data = if let settingsLease { try await self.connection.request( - request, ifCurrentServerLease: serverLease) + request, ifCurrentServerLease: settingsLease) } else { try await self.connection.request(request) } return try JSONDecoder().decode(OpenClawChatModelPatchResult.self, from: data) } - static func sessionSettingsRequest( - sessionKey: String, - agentID: String?, - patch: OpenClawChatSessionSettingsPatch) -> OpenClawChatGatewayRequest - { - OpenClawChatGatewayRequests.patchSessionSettings( - sessionKey: sessionKey, - agentID: agentID, - model: patch.model, - thinkingLevel: patch.thinkingLevel, - fastMode: patch.fastMode, - verboseLevel: patch.verboseLevel) - } - func acquireSessionSettingsRouteLease() async -> OpenClawChatSessionSettingsRouteLease? { guard await self.currentOutboxGatewayMatchesConnection() else { return nil } guard let serverLease = await connection.captureServerLease() else { return nil } diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift index 7d49d73e5ff0..589ea5e93110 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift @@ -118,6 +118,18 @@ enum GatewayWebSocketTestSupport { return Data(json.utf8) } + static func errorResponseData( + id: String, + code: String, + message: String, + details: [String: Any]) throws -> Data + { + try JSONSerialization.data(withJSONObject: [ + "type": "res", "id": id, "ok": false, + "error": ["code": code, "message": message, "details": details], + ]) + } + static func connectAuthFailureData( id: String, detailCode: String, diff --git a/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift index 27fad755fe19..f294f0ff0203 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift @@ -18,6 +18,45 @@ struct MacGatewayChatTransportMappingTests { } } + private actor SessionSettingsState { + private var entry: [String: Any] = [ + "sessionId": "current-session", "permissionMode": "guarded", + "model": "current-model", "toolOverrides": NSNull(), + ] + + func snapshot() throws -> Data { + try JSONSerialization.data(withJSONObject: self.entry, options: .sortedKeys) + } + + func respond(to data: Data) throws -> Data { + let frame = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let id = try #require(frame["id"] as? String) + let params = try #require(frame["params"] as? [String: Any]) + let key = try #require(params["key"] as? String) + for (expected, field) in [ + ("expectedSessionId", "sessionId"), + ("expectedPermissionMode", "permissionMode"), + ("expectedToolOverrides", "toolOverrides"), + ] { + if let value = params[expected], + !NSDictionary(dictionary: ["value": value]).isEqual(to: ["value": self.entry[field] ?? NSNull()]) + { + return try GatewayWebSocketTestSupport.errorResponseData( + id: id, code: "INVALID_REQUEST", + message: "Session \(key) changed before patch. Retry.", + details: ["reason": "session-changed"]) + } + } + for field in ["model", "permissionMode", "toolOverrides"] { + if let value = params[field] { self.entry[field] = value } + } + return try JSONSerialization.data(withJSONObject: [ + "type": "res", "id": id, "ok": true, + "payload": ["key": key, "entry": self.entry], + ]) + } + } + @Test(arguments: [false, true, nil] as [Bool?]) func `progress requests negotiate owner scope on the connected server`(supportsOwner: Bool?) async throws { let recorder = RequestRecorder() @@ -99,6 +138,7 @@ struct MacGatewayChatTransportMappingTests { connectInitially: Bool = true, mainSessionKey: String? = nil, capabilities: [String] = ["session-unread-ack-contract"], + settingsState: SessionSettingsState? = nil, _ run: @MainActor (MacGatewayChatTransport, RequestRecorder) async throws -> Void) async throws { let recorder = RequestRecorder() @@ -115,6 +155,10 @@ struct MacGatewayChatTransportMappingTests { if method != "health" { await recorder.append(data) } + if method == "sessions.patch", let settingsState { + try await socket.emitReceiveSuccess(.data(settingsState.respond(to: data))) + return + } let frame = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) let params = frame["params"] as? [String: Any] let payload = switch method { @@ -409,23 +453,123 @@ struct MacGatewayChatTransportMappingTests { await connection.shutdown() } - @Test func `session settings request preserves verbosity patch`() { - let request = MacGatewayChatTransport.sessionSettingsRequest( - sessionKey: "global", - agentID: "reviewer", - patch: OpenClawChatSessionSettingsPatch( - model: .some("openai/gpt-5.6-luna"), - thinkingLevel: .some(nil), - fastMode: .some(.on), - verboseLevel: .some("full"))) + @Test(arguments: [false, true]) + func `guarded settings writes carry CAS and replacements`(leased: Bool) async throws { + try await self.withSessionTransport( + capabilities: ["session-settings-contract", "session-settings-cas-v1"], + settingsState: SessionSettingsState()) + { transport, recorder in + let patch = OpenClawChatSessionSettingsPatch( + expectedSessionID: "current-session", + expectedPermissionMode: .some(.guarded), + expectedToolOverrides: .some(nil), + permissionMode: .some(.workspace), + toolOverrides: .some(.init(webSearch: false))) + let result: OpenClawChatModelPatchResult? + if leased { + let lease = try #require(await transport.acquireSessionSettingsRouteLease()) + result = try await lease.patchSessionSettings( + sessionKey: "global", + agentID: "reviewer", + patch: patch) + } else { + result = try await transport.patchSessionSettings( + sessionKey: "global", + agentID: "reviewer", + patch: patch) + } + #expect(result?.permissionMode == .workspace) + #expect(result?.toolOverrides?.webSearch == false) + let frames = await recorder.snapshot() + try #require(frames.count == 1) + let frame = try #require(JSONSerialization.jsonObject(with: frames[0]) as? [String: Any]) + #expect(frame["method"] as? String == "sessions.patch") + let params = try #require(frame["params"] as? [String: Any]) + #expect(NSDictionary(dictionary: params).isEqual(to: [ + "key": "global", "agentId": "reviewer", "expectedSessionId": "current-session", + "expectedPermissionMode": "guarded", "expectedToolOverrides": NSNull(), + "permissionMode": "workspace", "toolOverrides": ["webSearch": false], + ])) + } + } - #expect(request.method == "sessions.patch") - #expect(request.params["key"]?.value as? String == "global") - #expect(request.params["agentId"]?.value as? String == "reviewer") - #expect(request.params["model"]?.value as? String == "openai/gpt-5.6-luna") - #expect(request.params["thinkingLevel"]?.value is NSNull) - #expect(request.params["fastMode"]?.value as? Bool == true) - #expect(request.params["verboseLevel"]?.value as? String == "full") + @Test(arguments: [false, true], [ + OpenClawChatSessionSettingsPatch(expectedSessionID: "stale-session", model: .some("new-model")), + OpenClawChatSessionSettingsPatch(expectedPermissionMode: .some(.workspace), permissionMode: .some(.full)), + OpenClawChatSessionSettingsPatch( + expectedToolOverrides: .some(.init(webSearch: true)), toolOverrides: .some(.init(webSearch: false))), + ]) + func `stale settings writes propagate Gateway rejection without changing state`( + leased: Bool, patch: OpenClawChatSessionSettingsPatch) async throws + { + let state = SessionSettingsState() + let before = try await state.snapshot() + try await self.withSessionTransport( + capabilities: ["session-settings-contract", "session-settings-cas-v1"], + settingsState: state) + { transport, recorder in + do { + if leased { + let lease = try #require(await transport.acquireSessionSettingsRouteLease()) + _ = try await lease.patchSessionSettings(sessionKey: "global", agentID: nil, patch: patch) + } else { + _ = try await transport.patchSessionSettings(sessionKey: "global", agentID: nil, patch: patch) + } + Issue.record("Stale settings patch succeeded") + } catch let error as GatewayResponseError { + #expect(error.code == "INVALID_REQUEST") + #expect(error.detailsReason == "session-changed") + #expect(error.message == "Session global changed before patch. Retry.") + #expect(error.localizedDescription == + "sessions.patch: [INVALID_REQUEST] Session global changed before patch. Retry.") + } + #expect(try await state.snapshot() == before) + #expect(await recorder.snapshot().count == 1) + } + } + + @Test(arguments: [false, true], [ + [String](), ["session-settings-contract"], ["session-settings-cas-v1"], + ]) + func `unsupported settings guards fail before dispatch`(leased: Bool, capabilities: [String]) async throws { + try await self.withSessionTransport(capabilities: capabilities) { transport, recorder in + let patch = OpenClawChatSessionSettingsPatch( + expectedSessionID: "current-session", expectedPermissionMode: .some(nil), + permissionMode: .some(.workspace)) + await #expect(throws: OpenClawChatTransportSendError.notDispatched) { + if leased { + let lease = try #require(await transport.acquireSessionSettingsRouteLease()) + _ = try await lease.patchSessionSettings(sessionKey: "global", agentID: nil, patch: patch) + } else { + _ = try await transport.patchSessionSettings(sessionKey: "global", agentID: nil, patch: patch) + } + } + #expect(await recorder.snapshot().isEmpty) + } + } + + @Test(arguments: [false, true]) + func `unguarded settings writes preserve model thinking fast mode and verbosity`(leased: Bool) async throws { + try await self.withSessionTransport(capabilities: []) { transport, recorder in + let patch = OpenClawChatSessionSettingsPatch( + model: .some("openai/gpt-5.6-luna"), thinkingLevel: .some(nil), + fastMode: .some(.on), verboseLevel: .some("full")) + if leased { + let lease = try #require(await transport.acquireSessionSettingsRouteLease()) + _ = try await lease.patchSessionSettings(sessionKey: "global", agentID: "reviewer", patch: patch) + } else { + _ = try await transport.patchSessionSettings(sessionKey: "global", agentID: "reviewer", patch: patch) + } + let frames = await recorder.snapshot() + try #require(frames.count == 1) + let frame = try #require(JSONSerialization.jsonObject(with: frames[0]) as? [String: Any]) + #expect(frame["method"] as? String == "sessions.patch") + let params = try #require(frame["params"] as? [String: Any]) + #expect(NSDictionary(dictionary: params).isEqual(to: [ + "key": "global", "agentId": "reviewer", "model": "openai/gpt-5.6-luna", + "thinkingLevel": NSNull(), "fastMode": true, "verboseLevel": "full", + ])) + } } @Test func `scoped settings mutations keep the fixed owner for bare keys`() async throws { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayRequest.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayRequest.swift index 736dbeea02f3..11b488930fd3 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayRequest.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayRequest.swift @@ -325,6 +325,32 @@ public enum OpenClawChatGatewayRequests { verboseLevel: verboseLevel) } + public static func patchSessionSettings( + sessionKey: String, + agentID: String?, + patch: OpenClawChatSessionSettingsPatch, + supportsSessionSettingsContract: Bool, + supportsSessionSettingsCAS: Bool) throws -> OpenClawChatGatewayRequest + { + guard !patch.requiresSessionSettingsContract || supportsSessionSettingsContract, + !patch.requiresSessionSettingsCAS || supportsSessionSettingsCAS + else { throw OpenClawChatTransportSendError.notDispatched } + return self.patchSessionSettings( + sessionKey: sessionKey, + agentID: agentID, + expectedSessionID: patch.expectedSessionID, + expectedPermissionMode: patch.expectedPermissionMode, + expectedToolOverrides: patch.expectedToolOverrides, + model: patch.model, + thinkingLevel: patch.thinkingLevel, + fastMode: patch.fastMode, + verboseLevel: patch.verboseLevel, + permissionMode: patch.permissionMode, + toolOverrides: patch.toolOverrides, + supportsSessionSettingsContract: supportsSessionSettingsContract, + supportsSessionSettingsCAS: supportsSessionSettingsCAS) + } + public static func patchSessionSettings( sessionKey: String, agentID: String?, diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessions.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessions.swift index fcf7c0f629c5..00dbc1dc2a23 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessions.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessions.swift @@ -296,6 +296,15 @@ public struct OpenClawChatSessionSettingsPatch: Sendable, Equatable { public let permissionMode: OpenClawChatPermissionMode?? public let toolOverrides: OpenClawChatSessionToolOverrides?? + public var requiresSessionSettingsContract: Bool { + self.expectedSessionID != nil || self.permissionMode != nil || self.toolOverrides != nil + } + + public var requiresSessionSettingsCAS: Bool { + self.expectedPermissionMode != nil || self.expectedToolOverrides != nil || + self.permissionMode != nil || self.toolOverrides != nil + } + public init( expectedSessionID: String? = nil, expectedPermissionMode: OpenClawChatPermissionMode?? = nil, diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatGatewayRequestTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatGatewayRequestTests.swift index 83eafc4907e6..00a104fa1f24 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatGatewayRequestTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatGatewayRequestTests.swift @@ -311,67 +311,87 @@ struct ChatGatewayRequestTests { #expect(request.params["fastMode"]?.value as? String == "ultrafast") } - @Test func `settings patch request preserves permission and sparse tool overrides`() throws { + @Test func `settings patch builder maps every field including sparse overrides and resets`() throws { let overrides = OpenClawChatSessionToolOverrides( webSearch: false, skills: ["release": false], mcpServers: ["github": true], mcpToolsDeny: ["github": ["create_issue", "delete_issue"]]) - let request = OpenClawChatGatewayRequests.patchSessionSettings( + let request = try OpenClawChatGatewayRequests.patchSessionSettings( sessionKey: "global", agentID: "reviewer", - expectedSessionID: "sess-global", - expectedPermissionMode: .some(.guarded), - expectedToolOverrides: .some(OpenClawChatSessionToolOverrides(webSearch: false)), - permissionMode: .some(.workspace), - toolOverrides: .some(overrides), + patch: .init( + expectedSessionID: "sess-global", + expectedPermissionMode: .some(.guarded), + expectedToolOverrides: .some(.init(webSearch: false)), + model: .some("example/model"), + thinkingLevel: .some("high"), + fastMode: .some(.automatic), + verboseLevel: .some("full"), + permissionMode: .some(.workspace), + toolOverrides: .some(overrides)), supportsSessionSettingsContract: true, supportsSessionSettingsCAS: true) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let expected: [String: Any] = [ + "key": "global", "agentId": "reviewer", "expectedSessionId": "sess-global", + "expectedPermissionMode": "guarded", "expectedToolOverrides": ["webSearch": false], + "model": "example/model", "thinkingLevel": "high", "fastMode": "auto", "verboseLevel": "full", + "permissionMode": "workspace", + "toolOverrides": [ + "webSearch": false, "skills": ["release": false], "mcpServers": ["github": true], + "mcpToolsDeny": ["github": ["create_issue", "delete_issue"]], + ], + ] + #expect(request.method == "sessions.patch") + #expect(request.timeoutMs == 15000) + #expect(try encoder.encode(request.params) == JSONSerialization.data( + withJSONObject: expected, options: .sortedKeys)) - #expect(request.params["expectedSessionId"]?.value as? String == "sess-global") - #expect(request.params["expectedPermissionMode"]?.value as? String == "guarded") - #expect(request.params["permissionMode"]?.value as? String == "workspace") - let encoded = try JSONEncoder().encode(request.params["toolOverrides"]) - let value = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) - let expectedEncoded = try JSONEncoder().encode(request.params["expectedToolOverrides"]) - let expectedValue = try #require( - JSONSerialization.jsonObject(with: expectedEncoded) as? [String: Any]) - #expect(expectedValue["webSearch"] as? Bool == false) - #expect(value["webSearch"] as? Bool == false) - #expect((value["skills"] as? [String: Bool])?["release"] == false) - #expect((value["mcpServers"] as? [String: Bool])?["github"] == true) - #expect((value["mcpToolsDeny"] as? [String: [String]])?["github"] == [ - "create_issue", - "delete_issue", - ]) - - let reset = OpenClawChatGatewayRequests.patchSessionSettings( + let reset = try OpenClawChatGatewayRequests.patchSessionSettings( sessionKey: "global", agentID: "reviewer", - expectedPermissionMode: .some(.workspace), - expectedToolOverrides: .some(nil), - permissionMode: .some(nil), - toolOverrides: .some(nil), + patch: .init( + expectedPermissionMode: .some(nil), expectedToolOverrides: .some(nil), + model: .some(nil), thinkingLevel: .some(nil), fastMode: .some(nil), verboseLevel: .some(nil), + permissionMode: .some(nil), toolOverrides: .some(nil)), supportsSessionSettingsContract: true, supportsSessionSettingsCAS: true) - #expect(reset.params["permissionMode"]?.value is NSNull) - #expect(reset.params["expectedPermissionMode"]?.value as? String == "workspace") - #expect(reset.params["expectedToolOverrides"]?.value is NSNull) - #expect(reset.params["toolOverrides"]?.value is NSNull) + let cleared: [String: Any] = [ + "key": "global", "agentId": "reviewer", "expectedPermissionMode": NSNull(), + "expectedToolOverrides": NSNull(), "model": NSNull(), "thinkingLevel": NSNull(), + "fastMode": NSNull(), "verboseLevel": NSNull(), "permissionMode": NSNull(), "toolOverrides": NSNull(), + ] + #expect(try encoder.encode(reset.params) == JSONSerialization.data( + withJSONObject: cleared, options: .sortedKeys)) + } - let releasedGateway = OpenClawChatGatewayRequests.patchSessionSettings( - sessionKey: "global", - agentID: "reviewer", - expectedSessionID: "sess-global", - expectedPermissionMode: .some(nil), - expectedToolOverrides: .some(nil), - permissionMode: .some(.workspace), - toolOverrides: .some(overrides)) - #expect(releasedGateway.params["expectedSessionId"] == nil) - #expect(releasedGateway.params["expectedPermissionMode"] == nil) - #expect(releasedGateway.params["expectedToolOverrides"] == nil) - #expect(releasedGateway.params["permissionMode"] == nil) - #expect(releasedGateway.params["toolOverrides"] == nil) + @Test(arguments: [false, true], [false, true]) + func `settings patch builder gates contract and CAS independently`(contract: Bool, cas: Bool) throws { + let cases: [(patch: OpenClawChatSessionSettingsPatch, contract: Bool, cas: Bool)] = [ + (.init(model: .some("example/model")), false, false), + (.init(expectedSessionID: "sess-global", model: .some("example/model")), true, false), + (.init(expectedPermissionMode: .some(nil)), false, true), + (.init(expectedToolOverrides: .some(nil)), false, true), + (.init(permissionMode: .some(nil)), true, true), + (.init(toolOverrides: .some(nil)), true, true), + ] + for item in cases { + if (item.contract && !contract) || (item.cas && !cas) { + #expect(throws: OpenClawChatTransportSendError.notDispatched) { + try OpenClawChatGatewayRequests.patchSessionSettings( + sessionKey: "global", agentID: nil, patch: item.patch, + supportsSessionSettingsContract: contract, supportsSessionSettingsCAS: cas) + } + } else { + let request = try OpenClawChatGatewayRequests.patchSessionSettings( + sessionKey: "global", agentID: nil, patch: item.patch, + supportsSessionSettingsContract: contract, supportsSessionSettingsCAS: cas) + #expect(request.params["key"]?.value as? String == "global") + #expect(request.params.count == (item.patch.expectedSessionID == nil ? 2 : 3)) + } + } } @Test func `composer catalog requests preserve their owner scope`() {