diff --git a/README.md b/README.md index cb21f7c4..80edd0fc 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,11 @@ const val openClawPort = 18789 const val openClawGatewayToken = "your-gateway-token-here" ``` +Optional runtime settings (in-app **Settings**): +- **Model Override** -- if set, VisionClaw applies `/model ` for the current OpenClaw session and reapplies if changed. +- **Thinking Level** -- if set, VisionClaw applies `/think ` for the current session and reapplies if changed. +- Leaving either field empty explicitly clears that remote override and returns to gateway defaults (`/model default`, `/think default`, with fallback commands). + To find your Mac's Bonjour hostname: **System Settings > General > Sharing** -- it's shown at the top (e.g., `Johns-MacBook-Pro.local`). > Both iOS and Android also have an in-app Settings screen where you can change these values at runtime without editing source code. diff --git a/samples/CameraAccess/CameraAccess/Gemini/GeminiConfig.swift b/samples/CameraAccess/CameraAccess/Gemini/GeminiConfig.swift index 5c124f66..de887de4 100644 --- a/samples/CameraAccess/CameraAccess/Gemini/GeminiConfig.swift +++ b/samples/CameraAccess/CameraAccess/Gemini/GeminiConfig.swift @@ -48,6 +48,8 @@ enum GeminiConfig { static var openClawPort: Int { SettingsManager.shared.openClawPort } static var openClawHookToken: String { SettingsManager.shared.openClawHookToken } static var openClawGatewayToken: String { SettingsManager.shared.openClawGatewayToken } + static var openClawModel: String { SettingsManager.shared.openClawModel } + static var openClawThinking: String { SettingsManager.shared.openClawThinking } static func websocketURL() -> URL? { guard apiKey != "YOUR_GEMINI_API_KEY" && !apiKey.isEmpty else { return nil } diff --git a/samples/CameraAccess/CameraAccess/OpenClaw/OpenClawBridge.swift b/samples/CameraAccess/CameraAccess/OpenClaw/OpenClawBridge.swift index 86221668..a2481989 100644 --- a/samples/CameraAccess/CameraAccess/OpenClaw/OpenClawBridge.swift +++ b/samples/CameraAccess/CameraAccess/OpenClaw/OpenClawBridge.swift @@ -7,47 +7,93 @@ enum OpenClawConnectionState: Equatable { case unreachable(String) } +protocol OpenClawBridgeConfig { + var host: String { get } + var port: Int { get } + var gatewayToken: String { get } + var modelOverride: String { get } + var thinkingOverride: String { get } +} + +struct DefaultOpenClawBridgeConfig: OpenClawBridgeConfig { + var host: String { GeminiConfig.openClawHost } + var port: Int { GeminiConfig.openClawPort } + var gatewayToken: String { GeminiConfig.openClawGatewayToken } + var modelOverride: String { GeminiConfig.openClawModel } + var thinkingOverride: String { GeminiConfig.openClawThinking } +} + @MainActor class OpenClawBridge: ObservableObject { + private enum AppliedSessionOverrideState: Equatable { + case unknown + case value(String) + case cleared + } + @Published var lastToolCallStatus: ToolCallStatus = .idle @Published var connectionState: OpenClawConnectionState = .notConfigured private let session: URLSession private let pingSession: URLSession + private let config: OpenClawBridgeConfig + private let sessionKeyFactory: () -> String private var sessionKey: String private var conversationHistory: [[String: String]] = [] + private var appliedSessionModel: AppliedSessionOverrideState = .unknown + private var appliedSessionThinking: AppliedSessionOverrideState = .unknown private let maxHistoryTurns = 10 - init() { - let config = URLSessionConfiguration.default - config.timeoutIntervalForRequest = 120 - self.session = URLSession(configuration: config) + init( + session: URLSession? = nil, + pingSession: URLSession? = nil, + config: OpenClawBridgeConfig = DefaultOpenClawBridgeConfig(), + sessionKeyFactory: @escaping () -> String = OpenClawBridge.newSessionKey + ) { + if let session { + self.session = session + } else { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 120 + self.session = URLSession(configuration: config) + } - let pingConfig = URLSessionConfiguration.default - pingConfig.timeoutIntervalForRequest = 5 - self.pingSession = URLSession(configuration: pingConfig) + if let pingSession { + self.pingSession = pingSession + } else { + let pingConfig = URLSessionConfiguration.default + pingConfig.timeoutIntervalForRequest = 5 + self.pingSession = URLSession(configuration: pingConfig) + } - self.sessionKey = OpenClawBridge.newSessionKey() + self.config = config + self.sessionKeyFactory = sessionKeyFactory + self.sessionKey = sessionKeyFactory() } func checkConnection() async { - guard GeminiConfig.isOpenClawConfigured else { + guard isConfigured else { connectionState = .notConfigured return } connectionState = .checking - guard let url = URL(string: "\(GeminiConfig.openClawHost):\(GeminiConfig.openClawPort)/v1/chat/completions") else { + guard let url = gatewayURL else { connectionState = .unreachable("Invalid URL") return } var request = URLRequest(url: url) request.httpMethod = "GET" - request.setValue("Bearer \(GeminiConfig.openClawGatewayToken)", forHTTPHeaderField: "Authorization") + request.setValue("Bearer \(config.gatewayToken)", forHTTPHeaderField: "Authorization") do { let (_, response) = try await pingSession.data(for: request) - if let http = response as? HTTPURLResponse, (200...499).contains(http.statusCode) { - connectionState = .connected - NSLog("[OpenClaw] Gateway reachable (HTTP %d)", http.statusCode) + if let http = response as? HTTPURLResponse { + if (200...299).contains(http.statusCode) { + connectionState = .connected + NSLog("[OpenClaw] Gateway reachable (HTTP %d)", http.statusCode) + } else { + connectionState = .unreachable("HTTP \(http.statusCode)") + NSLog("[OpenClaw] Gateway check failed (HTTP %d)", http.statusCode) + } } else { connectionState = .unreachable("Unexpected response") } @@ -58,12 +104,156 @@ class OpenClawBridge: ObservableObject { } func resetSession() { - sessionKey = OpenClawBridge.newSessionKey() + sessionKey = sessionKeyFactory() conversationHistory = [] + appliedSessionModel = .unknown + appliedSessionThinking = .unknown NSLog("[OpenClaw] New session: %@", sessionKey) } - private static func newSessionKey() -> String { + private func normalizedSessionOverride(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed + } + + private func parseAssistantContent(from data: Data) -> String? { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let choices = json["choices"] as? [[String: Any]], + let first = choices.first, + let message = first["message"] as? [String: Any], + let content = message["content"] as? String else { + return nil + } + return content + } + + private var isConfigured: Bool { + let token = config.gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines) + let host = config.host.trimmingCharacters(in: .whitespacesAndNewlines) + return token != "YOUR_OPENCLAW_GATEWAY_TOKEN" + && !token.isEmpty + && host != "http://YOUR_MAC_HOSTNAME.local" + && !host.isEmpty + } + + private var gatewayURL: URL? { + URL(string: "\(config.host):\(config.port)/v1/chat/completions") + } + + private func sendSessionCommand( + command: String, + label: String + ) async -> ToolResult { + guard let url = gatewayURL else { + return .failure("Invalid gateway URL") + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(config.gatewayToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(sessionKey, forHTTPHeaderField: "x-openclaw-session-key") + + let body: [String: Any] = [ + "model": "openclaw", + "messages": [["role": "user", "content": command]], + "stream": false + ] + + do { + request.httpBody = try JSONSerialization.data(withJSONObject: body) + let (data, response) = try await session.data(for: request) + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 + guard (200...299).contains(statusCode) else { + return .failure("OpenClaw \(label) override failed (HTTP \(statusCode))") + } + + guard let content = parseAssistantContent(from: data)?.trimmingCharacters(in: .whitespacesAndNewlines), + !content.isEmpty else { + return .failure("OpenClaw \(label) override failed: empty response") + } + + let lower = content.lowercased() + if lower.contains("not allowed") || lower.contains("failed") || lower.contains("invalid") { + return .failure("OpenClaw \(label) override failed: \(content)") + } + return .success(content) + } catch { + return .failure("OpenClaw \(label) override error: \(error.localizedDescription)") + } + } + + private func sendFirstSuccessfulSessionCommand( + commands: [String], + label: String + ) async -> ToolResult { + var lastError = "OpenClaw \(label) override failed" + for command in commands { + switch await sendSessionCommand(command: command, label: label) { + case .success(let response): + return .success(response) + case .failure(let error): + lastError = error + } + } + return .failure(lastError) + } + + private func applySessionOverridesIfNeeded(toolName: String) async -> ToolResult? { + let desiredModel = normalizedSessionOverride(config.modelOverride) + let desiredThinking = normalizedSessionOverride(config.thinkingOverride)?.lowercased() + + if let model = desiredModel { + if appliedSessionModel != .value(model) { + switch await sendSessionCommand(command: "/model \(model)", label: "model") { + case .success: + appliedSessionModel = .value(model) + case .failure(let error): + lastToolCallStatus = .failed(toolName, error) + return .failure(error) + } + } + } else if appliedSessionModel != .cleared { + switch await sendFirstSuccessfulSessionCommand( + commands: ["/model default", "/model reset", "/model clear"], + label: "model clear" + ) { + case .success: + appliedSessionModel = .cleared + case .failure(let error): + lastToolCallStatus = .failed(toolName, error) + return .failure(error) + } + } + + if let thinking = desiredThinking { + if appliedSessionThinking != .value(thinking) { + switch await sendSessionCommand(command: "/think \(thinking)", label: "thinking") { + case .success: + appliedSessionThinking = .value(thinking) + case .failure(let error): + lastToolCallStatus = .failed(toolName, error) + return .failure(error) + } + } + } else if appliedSessionThinking != .cleared { + switch await sendFirstSuccessfulSessionCommand( + commands: ["/think default", "/think off"], + label: "thinking clear" + ) { + case .success: + appliedSessionThinking = .cleared + case .failure(let error): + lastToolCallStatus = .failed(toolName, error) + return .failure(error) + } + } + + return nil + } + + private nonisolated static func newSessionKey() -> String { let ts = ISO8601DateFormatter().string(from: Date()) return "agent:main:glass:\(ts)" } @@ -76,11 +266,15 @@ class OpenClawBridge: ObservableObject { ) async -> ToolResult { lastToolCallStatus = .executing(toolName) - guard let url = URL(string: "\(GeminiConfig.openClawHost):\(GeminiConfig.openClawPort)/v1/chat/completions") else { + guard let url = gatewayURL else { lastToolCallStatus = .failed(toolName, "Invalid URL") return .failure("Invalid gateway URL") } + if let overrideFailure = await applySessionOverridesIfNeeded(toolName: toolName) { + return overrideFailure + } + // Append the new user message to conversation history conversationHistory.append(["role": "user", "content": task]) @@ -91,7 +285,7 @@ class OpenClawBridge: ObservableObject { var request = URLRequest(url: url) request.httpMethod = "POST" - request.setValue("Bearer \(GeminiConfig.openClawGatewayToken)", forHTTPHeaderField: "Authorization") + request.setValue("Bearer \(config.gatewayToken)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue(sessionKey, forHTTPHeaderField: "x-openclaw-session-key") @@ -113,14 +307,16 @@ class OpenClawBridge: ObservableObject { let bodyStr = String(data: data, encoding: .utf8) ?? "no body" NSLog("[OpenClaw] Chat failed: HTTP %d - %@", code, String(bodyStr.prefix(200))) lastToolCallStatus = .failed(toolName, "HTTP \(code)") + if code == 401 { + return .failure("OpenClaw unauthorized (HTTP 401). Check OpenClaw Gateway Token in Settings.") + } + if code == 403 { + return .failure("OpenClaw forbidden (HTTP 403). Check gateway auth mode/token.") + } return .failure("Agent returned HTTP \(code)") } - if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let choices = json["choices"] as? [[String: Any]], - let first = choices.first, - let message = first["message"] as? [String: Any], - let content = message["content"] as? String { + if let content = parseAssistantContent(from: data) { // Append assistant response to history for continuity conversationHistory.append(["role": "assistant", "content": content]) NSLog("[OpenClaw] Agent result: %@", String(content.prefix(200))) diff --git a/samples/CameraAccess/CameraAccess/Secrets.swift.example b/samples/CameraAccess/CameraAccess/Secrets.swift.example index af66099a..b8b42c41 100644 --- a/samples/CameraAccess/CameraAccess/Secrets.swift.example +++ b/samples/CameraAccess/CameraAccess/Secrets.swift.example @@ -13,6 +13,8 @@ enum Secrets { static let openClawPort = 18789 static let openClawHookToken = "YOUR_OPENCLAW_HOOK_TOKEN" static let openClawGatewayToken = "YOUR_OPENCLAW_GATEWAY_TOKEN" + static let openClawModel = "" + static let openClawThinking = "" // OPTIONAL: WebRTC signaling server URL (for live POV streaming) // Run: cd samples/CameraAccess/server && npm install && npm start diff --git a/samples/CameraAccess/CameraAccess/Settings/SettingsManager.swift b/samples/CameraAccess/CameraAccess/Settings/SettingsManager.swift index 8a3704dc..d1e8a64b 100644 --- a/samples/CameraAccess/CameraAccess/Settings/SettingsManager.swift +++ b/samples/CameraAccess/CameraAccess/Settings/SettingsManager.swift @@ -11,6 +11,8 @@ final class SettingsManager { case openClawPort case openClawHookToken case openClawGatewayToken + case openClawModel + case openClawThinking case geminiSystemPrompt case webrtcSignalingURL } @@ -54,6 +56,16 @@ final class SettingsManager { set { defaults.set(newValue, forKey: Key.openClawGatewayToken.rawValue) } } + var openClawModel: String { + get { defaults.string(forKey: Key.openClawModel.rawValue) ?? Secrets.openClawModel } + set { defaults.set(newValue, forKey: Key.openClawModel.rawValue) } + } + + var openClawThinking: String { + get { defaults.string(forKey: Key.openClawThinking.rawValue) ?? Secrets.openClawThinking } + set { defaults.set(newValue, forKey: Key.openClawThinking.rawValue) } + } + // MARK: - WebRTC var webrtcSignalingURL: String { @@ -65,7 +77,8 @@ final class SettingsManager { func resetAll() { for key in [Key.geminiAPIKey, .geminiSystemPrompt, .openClawHost, .openClawPort, - .openClawHookToken, .openClawGatewayToken, .webrtcSignalingURL] { + .openClawHookToken, .openClawGatewayToken, .openClawModel, .openClawThinking, + .webrtcSignalingURL] { defaults.removeObject(forKey: key.rawValue) } } diff --git a/samples/CameraAccess/CameraAccess/Settings/SettingsView.swift b/samples/CameraAccess/CameraAccess/Settings/SettingsView.swift index f41b1214..b458913e 100644 --- a/samples/CameraAccess/CameraAccess/Settings/SettingsView.swift +++ b/samples/CameraAccess/CameraAccess/Settings/SettingsView.swift @@ -9,6 +9,8 @@ struct SettingsView: View { @State private var openClawPort: String = "" @State private var openClawHookToken: String = "" @State private var openClawGatewayToken: String = "" + @State private var openClawModel: String = "" + @State private var openClawThinking: String = "" @State private var geminiSystemPrompt: String = "" @State private var webrtcSignalingURL: String = "" @State private var showResetConfirmation = false @@ -74,6 +76,26 @@ struct SettingsView: View { .disableAutocorrection(true) .font(.system(.body, design: .monospaced)) } + + VStack(alignment: .leading, spacing: 4) { + Text("Model Override (optional)") + .font(.caption) + .foregroundColor(.secondary) + TextField("e.g. openai-codex/gpt-5.3-codex", text: $openClawModel) + .autocapitalization(.none) + .disableAutocorrection(true) + .font(.system(.body, design: .monospaced)) + } + + Picker("Thinking Level", selection: $openClawThinking) { + Text("Gateway Default").tag("") + Text("Off").tag("off") + Text("Minimal").tag("minimal") + Text("Low").tag("low") + Text("Medium").tag("medium") + Text("High").tag("high") + Text("XHigh").tag("xhigh") + } } Section(header: Text("WebRTC")) { @@ -134,6 +156,8 @@ struct SettingsView: View { openClawPort = String(settings.openClawPort) openClawHookToken = settings.openClawHookToken openClawGatewayToken = settings.openClawGatewayToken + openClawModel = settings.openClawModel + openClawThinking = settings.openClawThinking webrtcSignalingURL = settings.webrtcSignalingURL } @@ -146,6 +170,8 @@ struct SettingsView: View { } settings.openClawHookToken = openClawHookToken.trimmingCharacters(in: .whitespacesAndNewlines) settings.openClawGatewayToken = openClawGatewayToken.trimmingCharacters(in: .whitespacesAndNewlines) + settings.openClawModel = openClawModel.trimmingCharacters(in: .whitespacesAndNewlines) + settings.openClawThinking = openClawThinking.trimmingCharacters(in: .whitespacesAndNewlines) settings.webrtcSignalingURL = webrtcSignalingURL.trimmingCharacters(in: .whitespacesAndNewlines) } } diff --git a/samples/CameraAccess/CameraAccess/ViewModels/WearablesViewModel.swift b/samples/CameraAccess/CameraAccess/ViewModels/WearablesViewModel.swift index 348aa55a..1abf86e1 100644 --- a/samples/CameraAccess/CameraAccess/ViewModels/WearablesViewModel.swift +++ b/samples/CameraAccess/CameraAccess/ViewModels/WearablesViewModel.swift @@ -54,6 +54,10 @@ class WearablesViewModel: ObservableObject { self.registrationState = registrationState if self.showGettingStartedSheet == false && registrationState == .registered && previousState == .registering { self.showGettingStartedSheet = true + } else if registrationState == .available && previousState == .registering { + self.showError( + "Registration did not complete. Enable Developer Mode in Meta AI (Settings -> App Info -> tap App version 5x) and retry." + ) } } } diff --git a/samples/CameraAccess/CameraAccessTests/CameraAccessTests.swift b/samples/CameraAccess/CameraAccessTests/CameraAccessTests.swift index 5e9a28d5..156c3328 100644 --- a/samples/CameraAccess/CameraAccessTests/CameraAccessTests.swift +++ b/samples/CameraAccess/CameraAccessTests/CameraAccessTests.swift @@ -8,12 +8,15 @@ import Foundation import MWDATCore +#if canImport(MWDATMockDevice) import MWDATMockDevice +#endif import SwiftUI import XCTest @testable import CameraAccess +#if canImport(MWDATMockDevice) @MainActor class ViewModelIntegrationTests: XCTestCase { @@ -156,3 +159,291 @@ class ViewModelIntegrationTests: XCTestCase { XCTAssertTrue([.stopped, .waiting].contains(viewModel.streamingStatus)) } } +#endif + +private final class MutableOpenClawBridgeConfig: OpenClawBridgeConfig { + var host: String + var port: Int + var gatewayToken: String + var modelOverride: String + var thinkingOverride: String + + init( + host: String = "http://unit-test.local", + port: Int = 443, + gatewayToken: String = "unit-test-token", + modelOverride: String = "", + thinkingOverride: String = "" + ) { + self.host = host + self.port = port + self.gatewayToken = gatewayToken + self.modelOverride = modelOverride + self.thinkingOverride = thinkingOverride + } +} + +private final class MockOpenClawURLProtocol: URLProtocol { + struct Stub { + let statusCode: Int + let body: Data + } + + private static var stubs: [Stub] = [] + private static var requests: [URLRequest] = [] + private static let lock = NSLock() + + static func reset() { + lock.lock() + defer { lock.unlock() } + stubs.removeAll() + requests.removeAll() + } + + static func enqueueJSON(statusCode: Int = 200, assistantContent: String) { + let bodyObject: [String: Any] = [ + "id": "chatcmpl_test", + "object": "chat.completion", + "choices": [ + [ + "message": [ + "role": "assistant", + "content": assistantContent, + ], + ], + ], + ] + let body = try! JSONSerialization.data(withJSONObject: bodyObject) // swiftlint:disable:this force_try + lock.lock() + stubs.append(Stub(statusCode: statusCode, body: body)) + lock.unlock() + } + + static func recordedMessageContents() -> [String] { + lock.lock() + let captured = requests + lock.unlock() + return captured.compactMap { request in + guard let body = requestBody(for: request), + let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any], + let messages = json["messages"] as? [[String: Any]], + let last = messages.last, + let content = last["content"] as? String else { + return nil + } + return content + } + } + + private static func requestBody(for request: URLRequest) -> Data? { + if let body = request.httpBody { + return body + } + + guard let stream = request.httpBodyStream else { + return nil + } + + stream.open() + defer { stream.close() } + + var data = Data() + let bufferSize = 4096 + var buffer = [UInt8](repeating: 0, count: bufferSize) + + while stream.hasBytesAvailable { + let readCount = stream.read(&buffer, maxLength: bufferSize) + if readCount < 0 { + return nil + } + if readCount == 0 { + break + } + data.append(buffer, count: readCount) + } + + return data.isEmpty ? nil : data + } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + Self.lock.lock() + Self.requests.append(request) + let stub = Self.stubs.isEmpty ? nil : Self.stubs.removeFirst() + Self.lock.unlock() + + guard let stub else { + client?.urlProtocol(self, didFailWithError: NSError( + domain: "MockOpenClawURLProtocol", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "No stub available"])) + return + } + + let response = HTTPURLResponse( + url: request.url ?? URL(string: "http://unit-test.local")!, // swiftlint:disable:this force_unwrapping + statusCode: stub.statusCode, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: stub.body) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} + +@MainActor +final class OpenClawBridgeOverrideTests: XCTestCase { + + override func setUp() { + super.setUp() + MockOpenClawURLProtocol.reset() + } + + override func tearDown() { + MockOpenClawURLProtocol.reset() + super.tearDown() + } + + private func makeBridge(config: MutableOpenClawBridgeConfig) -> OpenClawBridge { + let sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [MockOpenClawURLProtocol.self] + let session = URLSession(configuration: sessionConfig) + return OpenClawBridge( + session: session, + pingSession: session, + config: config, + sessionKeyFactory: { "agent:main:test:session" } + ) + } + + private func unwrapSuccess(_ result: ToolResult, file: StaticString = #filePath, line: UInt = #line) -> String { + switch result { + case .success(let value): + return value + case .failure(let error): + XCTFail("Expected success, got failure: \(error)", file: file, line: line) + return "" + } + } + + private func unwrapFailure(_ result: ToolResult, file: StaticString = #filePath, line: UInt = #line) -> String { + switch result { + case .success(let value): + XCTFail("Expected failure, got success: \(value)", file: file, line: line) + return "" + case .failure(let error): + return error + } + } + + func testDelegateTaskAppliesInitialModelAndThinkingOverrides() async { + let config = MutableOpenClawBridgeConfig( + modelOverride: "openai-codex/gpt-5.3-codex", + thinkingOverride: "high" + ) + let bridge = makeBridge(config: config) + + MockOpenClawURLProtocol.enqueueJSON(assistantContent: "model override ok") + MockOpenClawURLProtocol.enqueueJSON(assistantContent: "thinking override ok") + MockOpenClawURLProtocol.enqueueJSON(assistantContent: "TASK_OK") + + let result = await bridge.delegateTask(task: "create test note") + XCTAssertEqual(unwrapSuccess(result), "TASK_OK") + XCTAssertEqual( + MockOpenClawURLProtocol.recordedMessageContents(), + ["/model openai-codex/gpt-5.3-codex", "/think high", "create test note"] + ) + } + + func testDelegateTaskReappliesChangedOverridesOnLaterTurn() async { + let config = MutableOpenClawBridgeConfig( + modelOverride: "openai/gpt-4.1", + thinkingOverride: "low" + ) + let bridge = makeBridge(config: config) + + MockOpenClawURLProtocol.enqueueJSON(assistantContent: "model1") + MockOpenClawURLProtocol.enqueueJSON(assistantContent: "think1") + MockOpenClawURLProtocol.enqueueJSON(assistantContent: "FIRST_OK") + + let first = await bridge.delegateTask(task: "first task") + XCTAssertEqual(unwrapSuccess(first), "FIRST_OK") + + config.modelOverride = "anthropic/claude-sonnet-4-5" + config.thinkingOverride = "medium" + + MockOpenClawURLProtocol.enqueueJSON(assistantContent: "model2") + MockOpenClawURLProtocol.enqueueJSON(assistantContent: "think2") + MockOpenClawURLProtocol.enqueueJSON(assistantContent: "SECOND_OK") + + let second = await bridge.delegateTask(task: "second task") + XCTAssertEqual(unwrapSuccess(second), "SECOND_OK") + XCTAssertEqual( + MockOpenClawURLProtocol.recordedMessageContents(), + [ + "/model openai/gpt-4.1", + "/think low", + "first task", + "/model anthropic/claude-sonnet-4-5", + "/think medium", + "second task", + ] + ) + } + + func testDelegateTaskClearsRemoteOverridesWhenSettingsBecomeEmpty() async { + let config = MutableOpenClawBridgeConfig( + modelOverride: "openai/gpt-4.1", + thinkingOverride: "high" + ) + let bridge = makeBridge(config: config) + + MockOpenClawURLProtocol.enqueueJSON(assistantContent: "model set") + MockOpenClawURLProtocol.enqueueJSON(assistantContent: "think set") + MockOpenClawURLProtocol.enqueueJSON(assistantContent: "FIRST_OK") + + let first = await bridge.delegateTask(task: "first task") + XCTAssertEqual(unwrapSuccess(first), "FIRST_OK") + + config.modelOverride = "" + config.thinkingOverride = "" + + MockOpenClawURLProtocol.enqueueJSON(assistantContent: "model cleared") + MockOpenClawURLProtocol.enqueueJSON(assistantContent: "thinking cleared") + MockOpenClawURLProtocol.enqueueJSON(assistantContent: "SECOND_OK") + + let second = await bridge.delegateTask(task: "second task") + XCTAssertEqual(unwrapSuccess(second), "SECOND_OK") + XCTAssertEqual( + MockOpenClawURLProtocol.recordedMessageContents(), + [ + "/model openai/gpt-4.1", + "/think high", + "first task", + "/model default", + "/think default", + "second task", + ] + ) + } + + func testDelegateTaskFailsWhenOverrideCommandFails() async { + let config = MutableOpenClawBridgeConfig( + modelOverride: "bad/model", + thinkingOverride: "" + ) + let bridge = makeBridge(config: config) + + MockOpenClawURLProtocol.enqueueJSON(statusCode: 500, assistantContent: "server error") + + let result = await bridge.delegateTask(task: "should not run") + let error = unwrapFailure(result) + XCTAssertTrue(error.contains("HTTP 500"), "Expected HTTP 500 error, got: \(error)") + XCTAssertEqual(MockOpenClawURLProtocol.recordedMessageContents(), ["/model bad/model"]) + } +}