diff --git a/Sources/XcodeMCPProxyRuntime/Session/DocumentationProvider.swift b/Sources/XcodeMCPProxyRuntime/Session/DocumentationProvider.swift index d0ebb39d..c0a56495 100644 --- a/Sources/XcodeMCPProxyRuntime/Session/DocumentationProvider.swift +++ b/Sources/XcodeMCPProxyRuntime/Session/DocumentationProvider.swift @@ -131,21 +131,25 @@ struct LiveDocumentationSearchServiceRepairer: DocumentationSearchServiceRepairi private static let configURLDefaultsKey = "IDEChatDocumentationSearchConfigURL" private let assetRoot: URL + private let currentOSVersion: @Sendable () -> String private let readConfigURLOverride: @Sendable () -> String? private let writeConfigURLOverride: @Sendable (String) -> Bool init( assetRoot: URL = DocumentationSearchAssetLocator.defaultAssetRoot, + currentOSVersion: @escaping @Sendable () -> String = + DocumentationSearchAssetLocator.currentOperatingSystemVersionString, readConfigURLOverride: @escaping @Sendable () -> String? = Self.currentConfigURLOverride, writeConfigURLOverride: @escaping @Sendable (String) -> Bool = Self.writeConfigURLOverride ) { self.assetRoot = assetRoot + self.currentOSVersion = currentOSVersion self.readConfigURLOverride = readConfigURLOverride self.writeConfigURLOverride = writeConfigURLOverride } func repairDocumentationSearch( - for _: XcodeProcessTarget + for target: XcodeProcessTarget ) async -> DocumentationSearchServiceRepairResult { let scan: DocumentationSearchAssetScan do { @@ -153,8 +157,18 @@ struct LiveDocumentationSearchServiceRepairer: DocumentationSearchServiceRepairi } catch { return .failed("asset_scan_failed: \(error)") } - guard let asset = DocumentationSearchAssetLocator.latestAsset(from: scan.assets) else { - return .skipped(scan.noAssetReason) + let hostOSVersion = currentOSVersion() + guard let asset = DocumentationSearchAssetLocator.bestHostCompatibleAsset( + for: target.xcodeVersion, + currentOSVersion: hostOSVersion, + from: scan.assets + ) else { + guard scan.assets.isEmpty == false else { + return .skipped(scan.noAssetReason) + } + return .skipped( + "no_host_compatible_documentation_asset current_os=\(hostOSVersion)" + ) } let configURLString = asset.configURL.path @@ -365,16 +379,26 @@ extension DocumentationProvider { } static func responseIsDocumentationProviderFailure(_ data: Data) -> Bool { - responseErrorTexts(in: data).contains { text in - let normalized = text.lowercased() - return normalized.contains("config.json") - || normalized.contains("documentation database") - || normalized.contains("asset is not installed") - || normalized.contains("unable to obtain asset location") - || normalized.contains("no matching asset") - || normalized.contains("cannot complete asset query") - || normalized.contains("cannot resolve asset query") - } + responseErrorTexts(in: data).contains(where: errorTextIsDocumentationProviderFailure) + } + + static func errorTextIsDocumentationProviderFailure(_ text: String) -> Bool { + let normalized = text.lowercased() + return normalized.contains("config.json") + || normalized.contains("documentation database") + || normalized.contains("asset is not installed") + || normalized.contains("unable to obtain asset location") + || normalized.contains("no matching asset") + || normalized.contains("cannot complete asset query") + || normalized.contains("cannot resolve asset query") + || errorTextIsTextEncoderInitializationFailure(normalized) + } + + static func errorTextIsTextEncoderInitializationFailure(_ text: String) -> Bool { + let normalized = text.lowercased() + return normalized.contains("text encoding failed") + || normalized.contains("failed to create text encoder configuration") + || normalized.contains("text embedding model file not found") } private static func replacingDocumentationSearch( @@ -664,14 +688,35 @@ enum DocumentationSearchAssetLocator { )) } - static func bestAsset( + static func bestHostCompatibleAsset( for targetXcodeVersion: String, currentOSVersion: String, from assets: [DocumentationSearchInstalledAsset] ) -> DocumentationSearchInstalledAsset? { - assets.max { lhs, rhs in - isBetter(rhs, than: lhs, targetXcodeVersion: targetXcodeVersion, currentOSVersion: currentOSVersion) - } + hostCompatibleAssetsOrderedByCompatibility( + for: targetXcodeVersion, + currentOSVersion: currentOSVersion, + from: assets + ).first + } + + static func hostCompatibleAssetsOrderedByCompatibility( + for targetXcodeVersion: String, + currentOSVersion: String, + from assets: [DocumentationSearchInstalledAsset] + ) -> [DocumentationSearchInstalledAsset] { + assets + .filter { + compareVersion($0.osVersion, currentOSVersion) != .orderedDescending + } + .sorted { lhs, rhs in + isBetter( + lhs, + than: rhs, + targetXcodeVersion: targetXcodeVersion, + currentOSVersion: currentOSVersion + ) + } } static func latestAsset( @@ -722,9 +767,6 @@ enum DocumentationSearchAssetLocator { if lhsRank.xcodeVersionDistance != rhsRank.xcodeVersionDistance { return lhsRank.xcodeVersionDistance < rhsRank.xcodeVersionDistance } - if lhsRank.notNewerThanCurrentOS != rhsRank.notNewerThanCurrentOS { - return lhsRank.notNewerThanCurrentOS - } if lhsRank.osVersionDistance != rhsRank.osVersionDistance { return lhsRank.osVersionDistance < rhsRank.osVersionDistance } @@ -739,7 +781,6 @@ enum DocumentationSearchAssetLocator { let sameXcodeMajor: Bool let notNewerThanTargetXcode: Bool let xcodeVersionDistance: Int - let notNewerThanCurrentOS: Bool let osVersionDistance: Int let documentationRelease: Int } @@ -758,7 +799,6 @@ enum DocumentationSearchAssetLocator { sameXcodeMajor: assetXcodeParts.first != nil && assetXcodeParts.first == targetXcodeParts.first, notNewerThanTargetXcode: compareVersion(asset.xcodeVersion, targetXcodeVersion) != .orderedDescending, xcodeVersionDistance: versionDistance(assetXcodeParts, targetXcodeParts), - notNewerThanCurrentOS: compareVersion(asset.osVersion, currentOSVersion) != .orderedDescending, osVersionDistance: versionDistance(assetOSParts, currentOSParts), documentationRelease: asset.documentationRelease ?? 0 ) @@ -1665,29 +1705,79 @@ actor LiveDocumentationSearchActionInvoker: DocumentationSearchActionInvoking { } private actor DocumentationAssetSelectionCache { + private struct SelectionKey: Hashable { + let appPath: String + let xcodeVersion: String + let currentOSVersion: String + } + private struct RootSignature: Equatable { let path: String let modificationDate: Date? } private var cachedRootSignature: RootSignature? - private var cachedAsset: DocumentationSearchInstalledAsset? + private var cachedAssets: [DocumentationSearchInstalledAsset]? + private var successfulAssetPathBySelectionKey: [SelectionKey: String] = [:] - func latestInstalledAsset(assetRoot: URL) -> DocumentationSearchInstalledAsset? { + func installedAssets( + assetRoot: URL, + target: XcodeProcessTarget, + currentOSVersion: String + ) -> [DocumentationSearchInstalledAsset] { let signature = Self.rootSignature(for: assetRoot) - if cachedRootSignature == signature, let cachedAsset { - return cachedAsset - } - guard let scan = try? DocumentationSearchAssetLocator.scanInstalledAssets(in: assetRoot) - else { - return nil + let assets: [DocumentationSearchInstalledAsset] + if cachedRootSignature == signature, let cachedAssets { + assets = cachedAssets + } else { + guard let scan = try? DocumentationSearchAssetLocator.scanInstalledAssets(in: assetRoot) + else { + return [] + } + cachedRootSignature = signature + cachedAssets = scan.assets + successfulAssetPathBySelectionKey.removeAll() + assets = scan.assets + } + var orderedAssets = DocumentationSearchAssetLocator + .hostCompatibleAssetsOrderedByCompatibility( + for: target.xcodeVersion, + currentOSVersion: currentOSVersion, + from: assets + ) + let key = SelectionKey( + appPath: target.appPath, + xcodeVersion: target.xcodeVersion, + currentOSVersion: currentOSVersion + ) + guard let successfulAssetPath = successfulAssetPathBySelectionKey[key], + let index = orderedAssets.firstIndex(where: { + $0.assetURL.path == successfulAssetPath + }), + index != orderedAssets.startIndex else { + return orderedAssets } - guard let asset = DocumentationSearchAssetLocator.latestAsset(from: scan.assets) else { - return nil + orderedAssets.insert(orderedAssets.remove(at: index), at: orderedAssets.startIndex) + return orderedAssets + } + + func recordSuccessfulAsset( + _ asset: DocumentationSearchInstalledAsset, + assetRoot: URL, + target: XcodeProcessTarget, + currentOSVersion: String + ) { + guard cachedRootSignature == Self.rootSignature(for: assetRoot), + cachedAssets?.contains(asset) == true else { + return } - cachedRootSignature = signature - cachedAsset = asset - return asset + successfulAssetPathBySelectionKey[ + SelectionKey( + appPath: target.appPath, + xcodeVersion: target.xcodeVersion, + currentOSVersion: currentOSVersion + ) + ] = asset.assetURL.path } private static func rootSignature(for assetRoot: URL) -> RootSignature { @@ -1710,22 +1800,26 @@ struct DocumentationSearchActionProvider: DocumentationSearchProviding { private let assetCache: DocumentationAssetSelectionCache private let invoker: any DocumentationSearchActionInvoking private let defaultTargetResolver: @Sendable () -> XcodeProcessTarget? + private let currentOSVersion: @Sendable () -> String init( assetRoot: URL = DocumentationSearchAssetLocator.defaultAssetRoot, invoker: any DocumentationSearchActionInvoking = LiveDocumentationSearchActionInvoker(), defaultTargetResolver: @escaping @Sendable () -> XcodeProcessTarget? = - Self.defaultXcodeProcessTarget + Self.defaultXcodeProcessTarget, + currentOSVersion: @escaping @Sendable () -> String = + DocumentationSearchAssetLocator.currentOperatingSystemVersionString ) { self.assetRoot = assetRoot self.invoker = invoker self.defaultTargetResolver = defaultTargetResolver + self.currentOSVersion = currentOSVersion assetCache = DocumentationAssetSelectionCache() } func descriptor(for target: XcodeProcessTarget) async -> JSONValue? { - guard await latestInstalledAsset() != nil, - let resolvedTarget = resolveTarget(target), + guard let resolvedTarget = resolveTarget(target), + await installedAssets(for: resolvedTarget).isEmpty == false, await invoker.isAvailable(for: resolvedTarget) else { return nil } @@ -1737,32 +1831,80 @@ struct DocumentationSearchActionProvider: DocumentationSearchProviding { for target: XcodeProcessTarget, timeout: TimeAmount? ) async throws -> Data { - guard timeout?.nanoseconds != 0 else { + guard timeout.map({ $0.nanoseconds > 0 }) ?? true else { throw TimeoutError() } let arguments = try Self.searchArguments(from: requestData) - guard let asset = await latestInstalledAsset(), - let resolvedTarget = resolveTarget(target) else { + guard let resolvedTarget = resolveTarget(target) else { throw UpstreamSlotScheduler.AcquisitionError.unavailable } - let output = try await invoker.invoke( - DocumentationSearchActionInvocation( - target: resolvedTarget, - asset: asset, - query: arguments.query, - frameworks: arguments.frameworks, - limit: arguments.limit - ), - timeout: timeout + let currentOSVersion = currentOSVersion() + let assets = await installedAssets( + for: resolvedTarget, + currentOSVersion: currentOSVersion ) - return try Self.makeResponse( - requestID: arguments.requestID, - output: output + guard assets.isEmpty == false else { + throw UpstreamSlotScheduler.AcquisitionError.unavailable + } + let deadline = Deadline.fromNow(timeout) + var lastTextEncoderInitializationError: (any Error)? + for asset in assets { + let remainingTimeout = deadline?.remaining() + if remainingTimeout?.nanoseconds == 0 { + throw TimeoutError() + } + do { + let output = try await invoker.invoke( + DocumentationSearchActionInvocation( + target: resolvedTarget, + asset: asset, + query: arguments.query, + frameworks: arguments.frameworks, + limit: arguments.limit + ), + timeout: remainingTimeout + ) + await assetCache.recordSuccessfulAsset( + asset, + assetRoot: assetRoot, + target: resolvedTarget, + currentOSVersion: currentOSVersion + ) + return try Self.makeResponse( + requestID: arguments.requestID, + output: output + ) + } catch is CancellationError { + throw CancellationError() + } catch { + guard Self.isTextEncoderInitializationFailure(error) else { + throw error + } + lastTextEncoderInitializationError = error + } + } + throw lastTextEncoderInitializationError + ?? UpstreamSlotScheduler.AcquisitionError.unavailable + } + + private func installedAssets( + for target: XcodeProcessTarget, + currentOSVersion: String? = nil + ) async -> [DocumentationSearchInstalledAsset] { + await assetCache.installedAssets( + assetRoot: assetRoot, + target: target, + currentOSVersion: currentOSVersion ?? self.currentOSVersion() ) } - private func latestInstalledAsset() async -> DocumentationSearchInstalledAsset? { - await assetCache.latestInstalledAsset(assetRoot: assetRoot) + private static func isTextEncoderInitializationFailure(_ error: any Error) -> Bool { + guard let controlPlaneError = error as? ControlPlane.Error, + case .invalidResponse(let message) = controlPlaneError else { + return false + } + return DocumentationProvider.ToolCatalog + .errorTextIsTextEncoderInitializationFailure(message) } private func resolveTarget(_ target: XcodeProcessTarget) -> XcodeProcessTarget? { @@ -3061,16 +3203,30 @@ actor DocumentationProviderManager: DocumentationProviderManaging { } private func promoteToActive(_ profile: CandidateProfile) async -> Bool { - let previous = activeProvider - activeProvider = ActiveProvider(profile: profile) - preparedProviders.removeValue(forKey: profile.target.processID) - guard let previous else { - return true + if let activeProvider, + activeProvider.profile.target.processID == profile.target.processID { + if preparedProviders[profile.target.processID]?.id == profile.id { + preparedProviders.removeValue(forKey: profile.target.processID) + } + if activeProvider.profile.id != profile.id, + let route = profile.route, + activeProvider.profile.route?.id != route.id, + preparedProviders.values.contains(where: { $0.route?.id == route.id }) == false + { + await closeTransportRouteIfPresent(profile, awaitTermination: isShutdown) + } + return false } - guard previous.profile.id != profile.id else { + guard let prepared = preparedProviders[profile.target.processID], + prepared.id == profile.id else { return false } - await closeTransportRouteIfPresent(previous.profile, awaitTermination: isShutdown) + preparedProviders.removeValue(forKey: profile.target.processID) + let previous = activeProvider + activeProvider = ActiveProvider(profile: prepared) + if let previous { + await closeTransportRouteIfPresent(previous.profile, awaitTermination: isShutdown) + } return true } diff --git a/Tests/XcodeMCPProxyRuntimeTests/DocumentationProviderTests.swift b/Tests/XcodeMCPProxyRuntimeTests/DocumentationProviderTests.swift index 15583bae..dd844697 100644 --- a/Tests/XcodeMCPProxyRuntimeTests/DocumentationProviderTests.swift +++ b/Tests/XcodeMCPProxyRuntimeTests/DocumentationProviderTests.swift @@ -23,15 +23,21 @@ private struct StubDocumentationSearchActionInvoker: DocumentationSearchActionIn let available: Bool let output: DocumentationSearchActionOutput let recorder: DocumentationSearchActionInvocationRecorder? + let failureMessagesByEmbeddingModelName: [String: String] + let timeoutEmbeddingModelNames: Set init( available: Bool = true, output: DocumentationSearchActionOutput, - recorder: DocumentationSearchActionInvocationRecorder? = nil + recorder: DocumentationSearchActionInvocationRecorder? = nil, + failureMessagesByEmbeddingModelName: [String: String] = [:], + timeoutEmbeddingModelNames: Set = [] ) { self.available = available self.output = output self.recorder = recorder + self.failureMessagesByEmbeddingModelName = failureMessagesByEmbeddingModelName + self.timeoutEmbeddingModelNames = timeoutEmbeddingModelNames } func isAvailable(for _: XcodeProcessTarget) async -> Bool { @@ -43,10 +49,103 @@ private struct StubDocumentationSearchActionInvoker: DocumentationSearchActionIn timeout _: TimeAmount? ) async throws -> DocumentationSearchActionOutput { await recorder?.record(invocation) + if timeoutEmbeddingModelNames.contains(invocation.asset.embeddingModelName) { + throw TimeoutError() + } + if let failureMessage = failureMessagesByEmbeddingModelName[ + invocation.asset.embeddingModelName + ] { + throw ControlPlane.Error.invalidResponse(failureMessage) + } return output } } +private actor ControllableDocumentationProviderTransport: DocumentationProviderRouting { + private let firstCallStarted: TestSignal + private let secondCallStarted: TestSignal + private var callContinuations: [CheckedContinuation] = [] + private var closedRouteIDs: [String] = [] + + init(firstCallStarted: TestSignal, secondCallStarted: TestSignal) { + self.firstCallStarted = firstCallStarted + self.secondCallStarted = secondCallStarted + } + + func openRoute( + for target: XcodeProcessTarget, + requestTimeout _: TimeAmount?, + initializeParams _: [String: JSONValue] + ) async throws -> DocumentationProviderRoute { + DocumentationProviderRoute( + id: "controllable-\(target.processID)", + target: target, + upstreamIndex: nil, + serverVersion: target.xcodeVersion + ) + } + + func toolsList( + route: DocumentationProviderRoute, + timeout _: TimeAmount? + ) async throws -> JSONValue { + .object([ + "tools": .array([ + documentationDescriptor(version: route.serverVersion), + ]), + ]) + } + + func callDocumentationSearch( + route _: DocumentationProviderRoute, + requestData _: Data, + timeout _: TimeAmount? + ) async throws -> Data { + try await withCheckedThrowingContinuation { continuation in + callContinuations.append(continuation) + switch callContinuations.count { + case 1: + firstCallStarted.signal() + case 2: + secondCallStarted.signal() + default: + break + } + } + } + + func respondToCall(at index: Int, with data: Data) { + callContinuations[index].resume(returning: data) + } + + func close(route: DocumentationProviderRoute) async { + closedRouteIDs.append(route.id) + } + + func closedRoutes() -> [String] { + closedRouteIDs + } +} + +private func makeTextEncoderFallbackAssets(in root: URL) throws { + try makeInstalledDocumentationAsset( + root: root, + name: "xcode-27-md7v2", + xcodeVersion: "27.0", + osVersion: "26.4", + documentationRelease: 950001, + embeddingModelName: "md7v2" + ) + try makeInstalledDocumentationAsset( + root: root, + name: "xcode-26-5-md8", + xcodeVersion: "26.5", + osVersion: "26.6", + documentationRelease: 900340, + embeddingModelName: "md8" + ) +} + private actor DocumentationSearchActionProcessRecorder: ProcessRunning { private var requests: [ProcessRequest] = [] @@ -1558,6 +1657,104 @@ struct DocumentationProviderTests { #expect(await localProvider.requestedQueries() == ["SwiftUI", "UIKit"]) } + @Test func concurrentNativeSuccessPreservesFallbackAfterNativeRouteCloses() + async throws + { + let target = xcodeProcessTarget(processID: 751, xcodeVersion: "26.6") + let firstCallStarted = TestSignal() + let secondCallStarted = TestSignal() + let transport = ControllableDocumentationProviderTransport( + firstCallStarted: firstCallStarted, + secondCallStarted: secondCallStarted + ) + let localProvider = StubDocumentationSearchProvider( + descriptor: documentationDescriptor(version: "asset-fallback"), + responseData: try makeDocumentationSearchResponse( + id: 149, + text: "{\"answer\":\"asset\"}" + ) + ) + let providerManager = DocumentationProviderManager( + discovery: StubXcodeTargetDiscovery(targets: [target]), + transport: transport, + providerSelectionTimeout: .seconds(1), + localSearchProvider: localProvider + ) + + let fallbackTask = Task { + try await providerManager.callDocumentationSearch( + requestData: makeDocumentationSearchRequest( + id: 149, + query: "EstimationTechnique" + ), + requestTimeoutOverride: .seconds(2) + ) + } + try await firstCallStarted.wait( + description: "waiting for first native DocumentationSearch call" + ) + + let nativeSuccessTask = Task { + try await providerManager.callDocumentationSearch( + requestData: makeDocumentationSearchRequest(id: 150, query: "UIView"), + requestTimeoutOverride: .seconds(2) + ) + } + try await secondCallStarted.wait( + description: "waiting for second native DocumentationSearch call" + ) + + let textEncoderFailure = try makeDocumentationSearchToolErrorResponse( + id: 149, + text: "Search failed for 'EstimationTechnique' with error: " + + "Error Domain=NSOSStatusErrorDomain Code=-18 " + + "UserInfo={NSLocalizedDescription=Text encoding failed ((null))}" + ) + await transport.respondToCall( + at: 0, + with: textEncoderFailure + ) + let fallbackOutcome = try await waitWithTimeout( + "waiting for installed asset fallback" + ) { + try await fallbackTask.value + } + guard case .handled(let fallbackData, let invalidatedProvider) = fallbackOutcome else { + Issue.record("expected handled fallback outcome, got \(fallbackOutcome)") + return + } + #expect(invalidatedProvider) + #expect(try toolContentText(in: fallbackData) == "{\"answer\":\"asset\"}") + + let nativeSuccess = try makeDocumentationSearchResponse( + id: 150, + text: "{\"answer\":\"native\"}" + ) + await transport.respondToCall( + at: 1, + with: nativeSuccess + ) + let nativeSuccessOutcome = try await waitWithTimeout( + "waiting for concurrent native response" + ) { + try await nativeSuccessTask.value + } + guard case .handled(let nativeData, _) = nativeSuccessOutcome else { + Issue.record("expected handled native outcome, got \(nativeSuccessOutcome)") + return + } + #expect(try toolContentText(in: nativeData) == "{\"answer\":\"native\"}") + + let update = await providerManager.toolListUpdate(requestTimeout: .seconds(1)) + let result = DocumentationProvider.ToolCatalog.applying( + update, + to: try jsonValue(["tools": []]) + ) + #expect(documentationDescriptorDescription(in: result) == "docs-asset-fallback") + #expect(await localProvider.requestedQueries() == ["EstimationTechnique"]) + #expect(await transport.closedRoutes() == ["controllable-751"]) + } + @Test func runtimeDocumentationTransportKeepsBorrowedRouteAfterInitialAssetFallbackTimeout() async throws { @@ -4048,6 +4245,192 @@ struct DocumentationProviderTests { #expect(await factory.requestCount(processID: target.processID, method: "tools/call") == 1) } + @Test func documentationProviderFallsBackToInstalledAssetWhenTextEncoderIsUnavailable() + async throws + { + let target = xcodeProcessTarget(processID: 125, xcodeVersion: "26.6") + let factory = ScriptedDocumentationSessionFactory( + plansByPID: [ + target.processID: [ + .init( + serverVersion: "26.6", + toolCount: 21, + includesDocumentationSearch: true, + firstDocumentationResponse: .toolErrorText( + "Search failed for 'EstimationTechnique' with error: " + + "Error Domain=NSOSStatusErrorDomain Code=-18 " + + "UserInfo={NSLocalizedDescription=Text encoding failed ((null))}" + ), + userCallResponses: [.successText("{\"answer\":\"after-error\"}")] + ), + ], + ] + ) + let localProvider = StubDocumentationSearchProvider( + descriptor: documentationDescriptor(version: "asset-fallback"), + responseData: try makeDocumentationSearchResponse( + id: 125, + text: "{\"answer\":\"asset\"}" + ) + ) + let manager = DocumentationProviderManager( + discovery: StubXcodeTargetDiscovery(targets: [target]), + sessionFactory: factory, + localSearchProvider: localProvider + ) + + let firstOutcome = try await manager.callDocumentationSearch( + requestData: makeDocumentationSearchRequest( + id: 125, + query: "EstimationTechnique" + ), + requestTimeoutOverride: .seconds(1) + ) + let secondOutcome = try await manager.callDocumentationSearch( + requestData: makeDocumentationSearchRequest(id: 126, query: "UIView"), + requestTimeoutOverride: .seconds(1) + ) + + guard case .handled(let firstData, let firstInvalidatedProvider) = firstOutcome, + case .handled(let secondData, let secondInvalidatedProvider) = secondOutcome else { + Issue.record("expected handled outcomes, got \(firstOutcome) and \(secondOutcome)") + return + } + #expect(firstInvalidatedProvider) + #expect(secondInvalidatedProvider == false) + #expect(try toolContentText(in: firstData) == "{\"answer\":\"asset\"}") + #expect(try toolContentText(in: secondData) == "{\"answer\":\"asset\"}") + #expect(await localProvider.requestedCallPIDs() == [target.processID, target.processID]) + #expect(await localProvider.requestedQueries() == ["EstimationTechnique", "UIView"]) + #expect(await factory.documentationQueries(for: target.processID) == ["EstimationTechnique"]) + #expect(await factory.requestCount(processID: target.processID, method: "tools/call") == 1) + } + + @Test func liveDocumentationSearchServiceRepairerPrefersHostCompatibleAssetOverExactXcodeVersion() + async throws + { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("xcode-doc-assets-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try makeInstalledDocumentationAsset( + root: root, + name: "xcode-27", + xcodeVersion: "27.0", + osVersion: "27.0", + documentationRelease: 950001 + ) + try makeInstalledDocumentationAsset( + root: root, + name: "xcode-26-5-old-os", + xcodeVersion: "26.5", + osVersion: "26.2", + documentationRelease: 900339 + ) + try makeInstalledDocumentationAsset( + root: root, + name: "xcode-26-5-current-os", + xcodeVersion: "26.5", + osVersion: "26.6", + documentationRelease: 900340 + ) + let writtenValues = NIOLockedValueBox<[String]>([]) + let repairer = LiveDocumentationSearchServiceRepairer( + assetRoot: root, + currentOSVersion: { "26.6.1" }, + readConfigURLOverride: { nil }, + writeConfigURLOverride: { value in + writtenValues.withLockedValue { $0.append(value) } + return true + } + ) + + let result = await repairer.repairDocumentationSearch( + for: xcodeProcessTarget(processID: 126, xcodeVersion: "27.0") + ) + + guard case .repaired(let report) = result else { + Issue.record("expected repaired result, got \(result)") + return + } + #expect(report.xcodeVersion == "26.5") + #expect(report.osVersion == "26.6") + #expect(report.documentationRelease == 900340) + #expect(report.changedDefault) + #expect(report.configURL.contains("xcode-26-5-current-os.asset/AssetData/config.json")) + #expect(writtenValues.withLockedValue { $0 } == [report.configURL]) + } + + @Test func liveDocumentationSearchServiceRepairerSkipsWithoutHostCompatibleAsset() + async throws + { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("xcode-doc-assets-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try makeInstalledDocumentationAsset( + root: root, + name: "xcode-27", + xcodeVersion: "27.0", + osVersion: "27.0", + documentationRelease: 950001 + ) + let writtenValues = NIOLockedValueBox<[String]>([]) + let repairer = LiveDocumentationSearchServiceRepairer( + assetRoot: root, + currentOSVersion: { "26.6.1" }, + readConfigURLOverride: { nil }, + writeConfigURLOverride: { value in + writtenValues.withLockedValue { $0.append(value) } + return true + } + ) + + let result = await repairer.repairDocumentationSearch( + for: xcodeProcessTarget(processID: 126, xcodeVersion: "27.0") + ) + + #expect( + result == .skipped( + "no_host_compatible_documentation_asset current_os=26.6.1" + ) + ) + #expect(writtenValues.withLockedValue { $0 }.isEmpty) + } + + @Test func documentationAssetLocatorExcludesAssetsNewerThanHostOS() + throws + { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("xcode-doc-assets-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try makeInstalledDocumentationAsset( + root: root, + name: "xcode-27-newer-os", + xcodeVersion: "27.0", + osVersion: "27.0", + documentationRelease: 950001 + ) + try makeInstalledDocumentationAsset( + root: root, + name: "xcode-26-5-current-os", + xcodeVersion: "26.5", + osVersion: "26.6", + documentationRelease: 900340 + ) + let scan = try DocumentationSearchAssetLocator.scanInstalledAssets(in: root) + + let orderedAssets = DocumentationSearchAssetLocator + .hostCompatibleAssetsOrderedByCompatibility( + for: "27.0", + currentOSVersion: "26.6.1", + from: scan.assets + ) + + #expect(orderedAssets.map(\.xcodeVersion) == ["26.5"]) + } + @Test func documentationAssetLocatorTreatsTrailingZeroXcodeVersionsAsExactMatch() throws { @@ -4072,7 +4455,7 @@ struct DocumentationProviderTests { let scan = try DocumentationSearchAssetLocator.scanInstalledAssets(in: root) let asset = try #require( - DocumentationSearchAssetLocator.bestAsset( + DocumentationSearchAssetLocator.bestHostCompatibleAsset( for: "26.0", currentOSVersion: "26.0", from: scan.assets @@ -4120,6 +4503,42 @@ struct DocumentationProviderTests { #expect(asset.assetURL.path.contains("xcode-27-new-release.asset")) } + @Test func documentationSearchActionProviderIsUnavailableWithOnlyNewerOSAsset() + async throws + { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("xcode-doc-assets-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try makeInstalledDocumentationAsset( + root: root, + name: "xcode-27-newer-os", + xcodeVersion: "27.0", + osVersion: "27.0", + documentationRelease: 950001 + ) + let recorder = DocumentationSearchActionInvocationRecorder() + let provider = DocumentationSearchActionProvider( + assetRoot: root, + invoker: StubDocumentationSearchActionInvoker( + output: DocumentationSearchActionOutput(documents: []), + recorder: recorder + ), + currentOSVersion: { "26.6.1" } + ) + let target = xcodeProcessTarget(processID: 122, xcodeVersion: "27.0") + + #expect(await provider.descriptor(for: target) == nil) + await #expect(throws: UpstreamSlotScheduler.AcquisitionError.self) { + try await provider.callDocumentationSearch( + requestData: makeDocumentationSearchRequest(id: 122, query: "UIView"), + for: target, + timeout: .seconds(1) + ) + } + #expect(await recorder.recordedValues().isEmpty) + } + @Test func documentationSearchActionProviderReturnsHelperOutput() async throws { @@ -4129,16 +4548,23 @@ struct DocumentationProviderTests { try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) try makeInstalledDocumentationAsset( root: root, - name: "xcode-26-5", + name: "xcode-26-5-old-os", xcodeVersion: "26.5", osVersion: "26.2", documentationRelease: 900339 ) + try makeInstalledDocumentationAsset( + root: root, + name: "xcode-26-5-current-os", + xcodeVersion: "26.5", + osVersion: "26.6", + documentationRelease: 900340 + ) try makeInstalledDocumentationAsset( root: root, name: "xcode-27", xcodeVersion: "27.0", - osVersion: "27.0", + osVersion: "26.4", documentationRelease: 950001 ) let recorder = DocumentationSearchActionInvocationRecorder() @@ -4156,7 +4582,8 @@ struct DocumentationProviderTests { ), ]), recorder: recorder - ) + ), + currentOSVersion: { "26.6.1" } ) #expect(await provider.descriptor(for: target) != nil) @@ -4196,13 +4623,213 @@ struct DocumentationProviderTests { let invocation = try #require(await recorder.recordedValues().first) #expect(invocation.target == target) - #expect(invocation.asset.xcodeVersion == "27.0") - #expect(invocation.asset.documentationRelease == 950001) + #expect(invocation.asset.xcodeVersion == "26.5") + #expect(invocation.asset.osVersion == "26.6") + #expect(invocation.asset.documentationRelease == 900340) #expect(invocation.query == "UIView") #expect(invocation.frameworks == ["UIKit"]) #expect(invocation.limit == 2) } + @Test func documentationSearchActionProviderCachesAssetsInsteadOfOneTargetSelection() + async throws + { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("xcode-doc-assets-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try makeInstalledDocumentationAsset( + root: root, + name: "xcode-26-5", + xcodeVersion: "26.5", + osVersion: "26.6", + documentationRelease: 900340 + ) + try makeInstalledDocumentationAsset( + root: root, + name: "xcode-27", + xcodeVersion: "27.0", + osVersion: "26.6", + documentationRelease: 950001 + ) + let recorder = DocumentationSearchActionInvocationRecorder() + let provider = DocumentationSearchActionProvider( + assetRoot: root, + invoker: StubDocumentationSearchActionInvoker( + output: DocumentationSearchActionOutput(documents: []), + recorder: recorder + ), + currentOSVersion: { "26.6.1" } + ) + let xcode26 = xcodeProcessTarget(processID: 127, xcodeVersion: "26.6") + let xcode27 = xcodeProcessTarget(processID: 128, xcodeVersion: "27.0") + + _ = try await provider.callDocumentationSearch( + requestData: makeDocumentationSearchRequest(id: 127, query: "UIView"), + for: xcode26, + timeout: .seconds(1) + ) + _ = try await provider.callDocumentationSearch( + requestData: makeDocumentationSearchRequest(id: 128, query: "SwiftUI"), + for: xcode27, + timeout: .seconds(1) + ) + + let invocations = await recorder.recordedValues() + #expect(invocations.map(\.target) == [xcode26, xcode27]) + #expect(invocations.map(\.asset.xcodeVersion) == ["26.5", "27.0"]) + } + + @Test func documentationSearchActionProviderRetriesTextEncoderFailureAndCachesSuccess() + async throws + { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("xcode-doc-assets-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try makeTextEncoderFallbackAssets(in: root) + let recorder = DocumentationSearchActionInvocationRecorder() + let provider = DocumentationSearchActionProvider( + assetRoot: root, + invoker: StubDocumentationSearchActionInvoker( + output: DocumentationSearchActionOutput(documents: []), + recorder: recorder, + failureMessagesByEmbeddingModelName: [ + "md7v2": "DocumentationSearchAction helper failed: Text encoding failed ((null))", + ] + ), + currentOSVersion: { "26.6.1" } + ) + let target = xcodeProcessTarget(processID: 129, xcodeVersion: "27.0") + + let firstResponse = try await provider.callDocumentationSearch( + requestData: makeDocumentationSearchRequest( + id: 129, + query: "EstimationTechnique" + ), + for: target, + timeout: .seconds(1) + ) + let secondResponse = try await provider.callDocumentationSearch( + requestData: makeDocumentationSearchRequest(id: 130, query: "UIView"), + for: target, + timeout: .seconds(1) + ) + + #expect(try responseID(in: firstResponse) == 129) + #expect(try responseID(in: secondResponse) == 130) + let invocations = await recorder.recordedValues() + #expect(invocations.map(\.asset.embeddingModelName) == ["md7v2", "md8", "md8"]) + } + + @Test func documentationSearchActionProviderDoesNotRetryWithAssetNewerThanHostOS() + async throws + { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("xcode-doc-assets-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try makeInstalledDocumentationAsset( + root: root, + name: "xcode-26-5-md8", + xcodeVersion: "26.5", + osVersion: "26.6", + documentationRelease: 900340, + embeddingModelName: "md8" + ) + try makeInstalledDocumentationAsset( + root: root, + name: "xcode-27-md7v2", + xcodeVersion: "27.0", + osVersion: "27.0", + documentationRelease: 950001, + embeddingModelName: "md7v2" + ) + let recorder = DocumentationSearchActionInvocationRecorder() + let provider = DocumentationSearchActionProvider( + assetRoot: root, + invoker: StubDocumentationSearchActionInvoker( + output: DocumentationSearchActionOutput(documents: []), + recorder: recorder, + failureMessagesByEmbeddingModelName: [ + "md8": "DocumentationSearchAction helper failed: Text encoding failed ((null))", + ] + ), + currentOSVersion: { "26.6.1" } + ) + + await #expect(throws: ControlPlane.Error.self) { + try await provider.callDocumentationSearch( + requestData: makeDocumentationSearchRequest(id: 133, query: "UIView"), + for: xcodeProcessTarget(processID: 133, xcodeVersion: "27.0"), + timeout: .seconds(1) + ) + } + #expect(await recorder.recordedValues().map(\.asset.embeddingModelName) == ["md8"]) + } + + @Test func documentationSearchActionProviderDoesNotRetryUnrelatedHelperFailure() + async throws + { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("xcode-doc-assets-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try makeTextEncoderFallbackAssets(in: root) + let recorder = DocumentationSearchActionInvocationRecorder() + let provider = DocumentationSearchActionProvider( + assetRoot: root, + invoker: StubDocumentationSearchActionInvoker( + output: DocumentationSearchActionOutput(documents: []), + recorder: recorder, + failureMessagesByEmbeddingModelName: [ + "md7v2": "DocumentationSearchAction helper failed: query-specific failure", + ] + ), + currentOSVersion: { "26.6.1" } + ) + + await #expect(throws: ControlPlane.Error.self) { + try await provider.callDocumentationSearch( + requestData: makeDocumentationSearchRequest(id: 131, query: "bad query"), + for: xcodeProcessTarget(processID: 131, xcodeVersion: "27.0"), + timeout: .seconds(1) + ) + } + + #expect(await recorder.recordedValues().map(\.asset.embeddingModelName) == ["md7v2"]) + } + + @Test func documentationSearchActionProviderDoesNotRetryTimeout() + async throws + { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("xcode-doc-assets-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try makeTextEncoderFallbackAssets(in: root) + let recorder = DocumentationSearchActionInvocationRecorder() + let provider = DocumentationSearchActionProvider( + assetRoot: root, + invoker: StubDocumentationSearchActionInvoker( + output: DocumentationSearchActionOutput(documents: []), + recorder: recorder, + timeoutEmbeddingModelNames: ["md7v2"] + ), + currentOSVersion: { "26.6.1" } + ) + + await #expect(throws: TimeoutError.self) { + try await provider.callDocumentationSearch( + requestData: makeDocumentationSearchRequest(id: 132, query: "UIView"), + for: xcodeProcessTarget(processID: 132, xcodeVersion: "27.0"), + timeout: .seconds(1) + ) + } + + #expect(await recorder.recordedValues().map(\.asset.embeddingModelName) == ["md7v2"]) + } + @Test func documentationSearchActionInvokerPassesLatestAssetWithActionDefaults() async throws { @@ -4586,7 +5213,7 @@ struct DocumentationProviderTests { #expect(await unavailableInvokerProvider.descriptor(for: target) == nil) } - @Test func documentationSearchActionProviderHonorsSearchTimeout() + @Test func documentationSearchActionProviderRejectsNonPositiveSearchTimeout() async throws { let root = FileManager.default.temporaryDirectory @@ -4600,6 +5227,7 @@ struct DocumentationProviderTests { osVersion: "26.2", documentationRelease: 900339 ) + let recorder = DocumentationSearchActionInvocationRecorder() let provider = DocumentationSearchActionProvider( assetRoot: root, invoker: StubDocumentationSearchActionInvoker( @@ -4611,18 +5239,22 @@ struct DocumentationProviderTests { score: 0.92, kind: "symbol" ), - ]) + ]), + recorder: recorder ) ) let target = xcodeProcessTarget(processID: 125, xcodeVersion: "26.6") - await #expect(throws: TimeoutError.self) { - try await provider.callDocumentationSearch( - requestData: makeDocumentationSearchRequest(id: 125, query: "UIView"), - for: target, - timeout: .nanoseconds(0) - ) + for timeout in [TimeAmount.nanoseconds(0), .nanoseconds(-1)] { + await #expect(throws: TimeoutError.self) { + try await provider.callDocumentationSearch( + requestData: makeDocumentationSearchRequest(id: 125, query: "UIView"), + for: target, + timeout: timeout + ) + } } + #expect(await recorder.recordedValues().isEmpty) } @Test func documentationProviderBackgroundDiscoveryRemovesStaleDescriptorWhenAbsent() diff --git a/Tests/XcodeMCPProxyRuntimeTests/RuntimeCoordinatorTestSupport.swift b/Tests/XcodeMCPProxyRuntimeTests/RuntimeCoordinatorTestSupport.swift index 2e72da49..f6e0e3cd 100644 --- a/Tests/XcodeMCPProxyRuntimeTests/RuntimeCoordinatorTestSupport.swift +++ b/Tests/XcodeMCPProxyRuntimeTests/RuntimeCoordinatorTestSupport.swift @@ -484,7 +484,8 @@ func makeInstalledDocumentationAsset( name: String, xcodeVersion: String, osVersion: String, - documentationRelease: Int + documentationRelease: Int, + embeddingModelName: String? = nil ) throws { let assetURL = root.appendingPathComponent("\(name).asset", isDirectory: true) let assetDataURL = assetURL.appendingPathComponent("AssetData", isDirectory: true) @@ -503,7 +504,10 @@ func makeInstalledDocumentationAsset( try PropertyListEncoder().encode(plist).write( to: assetURL.appendingPathComponent("Info.plist", isDirectory: false) ) - try Data("{}".utf8).write( + let config: [String: String] = embeddingModelName.map { + ["embeddingModelName": $0] + } ?? [:] + try JSONEncoder().encode(config).write( to: assetDataURL.appendingPathComponent("config.json", isDirectory: false) ) try Data().write(