diff --git a/apps/swift-ios/Core/T3Client+ReviewGuide.swift b/apps/swift-ios/Core/T3Client+ReviewGuide.swift index bd5d56ff132ba..8956376f6a635 100644 --- a/apps/swift-ios/Core/T3Client+ReviewGuide.swift +++ b/apps/swift-ios/Core/T3Client+ReviewGuide.swift @@ -107,12 +107,12 @@ public struct PullRequestReviewGuideResult: Codable, Equatable, Sendable { extension T3Client { // Decode only this setting so older hosts remain gated without changing shared settings models. - private struct ReviewGuideSettings: Decodable { + private struct ReviewGuideSettings: Decodable, Sendable { let reviewGuidesEnabled: Bool? } public func reviewGuidesEnabled() async throws -> Bool { - let settings = try await rpc.request("server.getSettings", as: ReviewGuideSettings.self) + let settings = try await serverSettings(as: ReviewGuideSettings.self) return settings.reviewGuidesEnabled == true } diff --git a/apps/swift-ios/Core/T3Client.swift b/apps/swift-ios/Core/T3Client.swift index d7ac828502c2e..edb49243059fe 100644 --- a/apps/swift-ios/Core/T3Client.swift +++ b/apps/swift-ios/Core/T3Client.swift @@ -241,7 +241,20 @@ public actor T3Client { /// Read before replacing an override entry, including changes from other clients. public func serverSettings() async throws -> ServerSettingsSnapshot { - try await rpc.request("server.getSettings", as: ServerSettingsSnapshot.self) + try await serverSettings(as: ServerSettingsSnapshot.self) + } + + private struct SettingsConfig: Decodable, Sendable { + let settings: Settings + } + + func serverSettings(as type: Settings.Type) async throws -> Settings { + // Bypass the subscription cache before read-modify-write operations. + let config = try await rpc.request( + RPCMethod.serverGetConfig.rawValue, + as: SettingsConfig.self + ) + return config.settings } public func updateSettings(_ change: ServerSettingsChange) async throws @@ -300,7 +313,7 @@ public actor T3Client { } public func setProviderEnabled(instanceID: String, driver: String, enabled: Bool) async throws { - let settings = try await rpc.request("server.getSettings", as: JSONValue.self) + let settings = try await serverSettings(as: JSONValue.self) let patch = ProviderSettingsPatch.enabled(settings: settings, instanceID: instanceID, driver: driver, enabled: enabled) let _: JSONValue = try await rpc.request("server.updateSettings", payload: .object(["patch": patch]), as: JSONValue.self) } diff --git a/apps/swift-ios/Tests/CoreTests/T3ClientServerConfigTests.swift b/apps/swift-ios/Tests/CoreTests/T3ClientServerConfigTests.swift index 0a48dc87812dc..1640ef71cefa4 100644 --- a/apps/swift-ios/Tests/CoreTests/T3ClientServerConfigTests.swift +++ b/apps/swift-ios/Tests/CoreTests/T3ClientServerConfigTests.swift @@ -3,6 +3,59 @@ import XCTest @MainActor final class T3ClientServerConfigTests: XCTestCase { + func testSettingsReadsStayFreshWhenTheLegacyMethodIsUnavailable() async throws { + let connection = ServerConfigTestConnection(mode: .snapshot) + let client = makeClient(connection: connection) + let cached = try await client.serverConfig() + XCTAssertEqual(cached.settings?.newWorktreesStartFromOrigin, false) + + await connection.replaceSettings(.object([ + "newWorktreesStartFromOrigin": .bool(true), + "favorites": .array([.object(["provider": .string("codex"), "model": .string("gpt-5")])]), + ])) + let settings = try await client.serverSettings() + XCTAssertTrue(settings.newWorktreesStartFromOrigin) + XCTAssertEqual(settings.favorites?.first?.model, "gpt-5") + let tags = await connection.tags() + XCTAssertFalse(tags.contains("server.getSettings")) + await client.disconnect() + } + + func testProviderToggleAndReviewGuidesReadSettingsFromConfiguration() async throws { + let connection = ServerConfigTestConnection(mode: .snapshot) + let client = makeClient(connection: connection) + _ = try await client.serverConfig() + await connection.replaceSettings(.object([ + "reviewGuidesEnabled": .bool(true), + "providerInstances": .object([ + "codex": .object([ + "driver": .string("codex"), "enabled": .bool(true), + "config": .object(["binaryPath": .string("/custom/codex"), "futureOption": .bool(true)]), + ]), + "other": .object(["driver": .string("claude"), "enabled": .bool(true)]), + ]), + ])) + try await client.setProviderEnabled(instanceID: "codex", driver: "codex", enabled: false) + let writes = await connection.payloads(for: "server.updateSettings") + let instances = try XCTUnwrap(writes.first?["patch"]?["providerInstances"]) + XCTAssertEqual(instances["codex"]?["enabled"], .bool(false)) + XCTAssertEqual(instances["codex"]?["config"]?["binaryPath"], .string("/custom/codex")) + XCTAssertEqual(instances["codex"]?["config"]?["futureOption"], .bool(true)) + XCTAssertEqual(instances["other"]?["enabled"], .bool(true)) + + let enabled = try await client.reviewGuidesEnabled() + XCTAssertTrue(enabled) + await connection.replaceSettings(.object(["reviewGuidesEnabled": .bool(false)])) + let disabled = try await client.reviewGuidesEnabled() + XCTAssertFalse(disabled) + await connection.replaceSettings(.object([:])) + let unsupported = try await client.reviewGuidesEnabled() + XCTAssertFalse(unsupported) + let tags = await connection.tags() + XCTAssertFalse(tags.contains("server.getSettings")) + await client.disconnect() + } + func testQuestionAttachmentsRequireTheServerCapabilityBeforeUploading() async throws { let connection = ServerConfigTestConnection(mode: .snapshot) let client = makeClient(connection: connection) @@ -464,6 +517,7 @@ private actor ServerConfigTestConnection: WebSocketConnection { private let failResetCreditSend: Bool private let supportsQuestionAttachments: Bool? private var requestTags: [String] = [] + private var settingsOverride: JSONValue? private var requestPayloads: [String: [JSONValue]] = [:] private var subscriptionRequestID: Int? private var refreshRequestID: Int? @@ -500,6 +554,10 @@ private actor ServerConfigTestConnection: WebSocketConnection { } case "server.getConfig": try enqueue(success(id: id, value: config(id: "codex-old"))) + case "server.getSettings": + try enqueue(failure(id: id, message: "Unknown request tag: server.getSettings")) + case "server.updateSettings": + try enqueue(success(id: id, value: settingsOverride ?? .object([:]))) case "server.refreshProviders": refreshRequestID = id case "attachments.createUploadUrl": @@ -530,6 +588,7 @@ private actor ServerConfigTestConnection: WebSocketConnection { } func tags() -> [String] { requestTags } + func replaceSettings(_ settings: JSONValue) { settingsOverride = settings } func payloads(for tag: String) -> [JSONValue] { requestPayloads[tag] ?? [] } func waitForRequestCount(_ count: Int) async { @@ -592,7 +651,7 @@ private actor ServerConfigTestConnection: WebSocketConnection { } return .object([ "providers": .array([provider(id: id)]), - "settings": .object([ + "settings": settingsOverride ?? .object([ "defaultThreadEnvMode": .string("worktree"), "newWorktreesStartFromOrigin": .bool(false), ]), diff --git a/apps/swift-ios/Tests/FeatureTests/NativeMessageDeliveryTests.swift b/apps/swift-ios/Tests/FeatureTests/NativeMessageDeliveryTests.swift index ce5c60fe89103..a0a9f4264068e 100644 --- a/apps/swift-ios/Tests/FeatureTests/NativeMessageDeliveryTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/NativeMessageDeliveryTests.swift @@ -440,6 +440,7 @@ private actor DeliveryWebSocketConnection: WebSocketConnection { let request = try JSONDecoder.t3.decode(JSONValue.self, from: data) guard let id = request["id"], let tag = request["tag"]?.stringValue else { return } let config: JSONValue = .object([ + "settings": .object([:]), "providers": .array([.object([ "instanceId": .string("codex"), "driver": .string("codex"), "enabled": .bool(true), "installed": .bool(true), "status": .string("ready"), @@ -463,8 +464,6 @@ private actor DeliveryWebSocketConnection: WebSocketConnection { value = .object(["sequence": .number(101)]) case RPCMethod.serverGetConfig.rawValue: value = config - case "server.getSettings": - value = .object([:]) case RPCMethod.subscribeServerConfig.rawValue: try enqueue(.object([ "_tag": .string("Chunk"), "requestId": id, diff --git a/apps/swift-ios/Tests/FeatureTests/NativeMultiEnvironmentTests.swift b/apps/swift-ios/Tests/FeatureTests/NativeMultiEnvironmentTests.swift index 6016eb151b9f3..65a5f34a27d76 100644 --- a/apps/swift-ios/Tests/FeatureTests/NativeMultiEnvironmentTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/NativeMultiEnvironmentTests.swift @@ -2149,7 +2149,7 @@ private actor MultiEnvironmentHTTPTransport: HTTPTransport { ModelSelection(instanceId: "codex", model: "gpt-5.6-sol"), ModelSelection(instanceId: "claudeAgent", model: "claude-opus-4-1"), ] - return .object(["providers": .array(selections.map { selection in .object([ + return .object(["settings": .object([:]), "providers": .array(selections.map { selection in .object([ "instanceId": .string(selection.instanceId), "driver": .string(selection.instanceId), "enabled": .bool(true), "installed": .bool(true), "status": .string("ready"), @@ -2314,9 +2314,9 @@ private actor MultiEnvironmentFixtureSocket: WebSocketConnection { case RPCMethod.subscribeServerConfig.rawValue: response = .object(["_tag": .string("Chunk"), "requestId": id, "values": .array([.object(["type": .string("snapshot"), "config": await transport.serverConfig(host: host)])])]) - case "server.getSettings": + case RPCMethod.serverGetConfig.rawValue: response = .object(["_tag": .string("Exit"), "requestId": id, - "exit": .object(["_tag": .string("Success"), "value": .object([:])])]) + "exit": .object(["_tag": .string("Success"), "value": await transport.serverConfig(host: host)])]) case RPCMethod.subscribeShell.rawValue where interruptShellSubscriptions: // Let startup load configuration, then break live shell delivery. throw URLError(.networkConnectionLost) @@ -2448,8 +2448,8 @@ private actor MultiEnvironmentConfigurationServer { case "projects.ensureScratch": scratchRequestHosts.append(host) value = .object(["projectId": .string("scratch")]) - case "server.getSettings": - value = .object(settingsByHost[host] ?? [:]) + case RPCMethod.serverGetConfig.rawValue: + value = config(host: host) case RPCMethod.subscribeVCSStatus.rawValue: guard let cwd = request["payload"]?["cwd"]?.stringValue else { throw URLError(.badServerResponse) diff --git a/apps/swift-ios/Tests/FeatureTests/NativeRetryIdentityTests.swift b/apps/swift-ios/Tests/FeatureTests/NativeRetryIdentityTests.swift index a94827ba80691..d1ef0a9c794ce 100644 --- a/apps/swift-ios/Tests/FeatureTests/NativeRetryIdentityTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/NativeRetryIdentityTests.swift @@ -453,8 +453,7 @@ private actor ConcurrentBootstrapWebSocketConnection: WebSocketConnection { connectionWaiters.removeAll() } if request["tag"]?.stringValue == RPCMethod.serverGetConfig.rawValue - || request["tag"]?.stringValue == RPCMethod.subscribeServerConfig.rawValue - || request["tag"]?.stringValue == "server.getSettings", + || request["tag"]?.stringValue == RPCMethod.subscribeServerConfig.rawValue, let response = try retryConfigResponse(for: request) { enqueue(response) return @@ -760,8 +759,7 @@ private actor AmbiguousDispatchWebSocketConnection: WebSocketConnection { connectionWaiters.removeAll() } if request["tag"]?.stringValue == RPCMethod.serverGetConfig.rawValue - || request["tag"]?.stringValue == RPCMethod.subscribeServerConfig.rawValue - || request["tag"]?.stringValue == "server.getSettings", + || request["tag"]?.stringValue == RPCMethod.subscribeServerConfig.rawValue, let response = try retryConfigResponse(for: request) { enqueue(response) return @@ -825,8 +823,7 @@ private actor PartialBootstrapWebSocketConnection: WebSocketConnection { guard [RPCMethod.dispatchCommand.rawValue, "orchestration.launchThread"].contains(request["tag"]?.stringValue ?? ""), let payload = request["payload"] else { if request["tag"]?.stringValue == RPCMethod.serverGetConfig.rawValue - || request["tag"]?.stringValue == RPCMethod.subscribeServerConfig.rawValue - || request["tag"]?.stringValue == "server.getSettings", + || request["tag"]?.stringValue == RPCMethod.subscribeServerConfig.rawValue, let response = try retryConfigResponse(for: request) { enqueue(response) } @@ -885,7 +882,8 @@ private actor PartialBootstrapWebSocketConnection: WebSocketConnection { private func retryConfigResponse(for request: JSONValue) throws -> Data? { guard case let .number(requestID)? = request["id"] else { return nil } - let config: JSONValue = request["tag"]?.stringValue == "server.getSettings" ? .object([:]) : .object([ + let config: JSONValue = .object([ + "settings": .object([:]), "providers": .array([.object([ "instanceId": .string("codex"), "driver": .string("codex"), "enabled": .bool(true), "installed": .bool(true), "status": .string("ready"), diff --git a/apps/swift-ios/Tests/FeatureTests/NativeWorkspacePermissionTests.swift b/apps/swift-ios/Tests/FeatureTests/NativeWorkspacePermissionTests.swift index 45f7af9c97d3a..35fb4a6cae24a 100644 --- a/apps/swift-ios/Tests/FeatureTests/NativeWorkspacePermissionTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/NativeWorkspacePermissionTests.swift @@ -214,7 +214,7 @@ private actor WorkspacePermissionServer: HTTPTransport { } let value: JSONValue switch method { - case "server.getSettings": value = .object([:]) + case RPCMethod.serverGetConfig.rawValue: value = .object(["settings": .object([:])]) case "git.preparePullRequestThread": value = .object(["branch": .string("pr-1"), "worktreePath": .string("/work/pr-1"), "isOnPullRequestHead": .bool(true)])