diff --git a/AGENTS.md b/AGENTS.md index cd8d70b..1d50458 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,10 +6,20 @@ Native macOS SwiftUI app for local LLMs on Apple Silicon via MLX. Provides a cha **Always use `./build.sh` to build the project** — never call `xcodebuild` directly. The script runs xcodegen first (to pick up new/removed files) and uses the correct scheme, destination, and build directory. +**Always use `./test.sh` to run tests** — it regenerates the Xcode project first and runs the shared `MLXServer` test scheme so test runs are reproducible. + +Tests are required for finished work when the change is reasonably testable. +Relevant tests must exist and must pass before work is considered complete. + +Pre-existing errors don't exist: every error is your responsibility and you have to fix it before claiming you are done. + ```bash # Build (requires xcodegen: brew install xcodegen) ./build.sh +# Test +./test.sh + # Run open "build/Debug/MLX Server.app" ``` diff --git a/MLXServer.xcodeproj/project.pbxproj b/MLXServer.xcodeproj/project.pbxproj index abb58d5..b205fd9 100644 --- a/MLXServer.xcodeproj/project.pbxproj +++ b/MLXServer.xcodeproj/project.pbxproj @@ -25,12 +25,15 @@ 5946258F1DE88CE904584E0B /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 944C699FBB76C734C9DF2F2E /* ContentView.swift */; }; 5C1E8FE1C521914CEF98D3AA /* ChatMessagesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1A5E8B1C9F2BC4D262C53A /* ChatMessagesView.swift */; }; 621B7E4382199AC1378F5F9C /* StatusBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0EAB35D7130D56B9E7484BA /* StatusBarView.swift */; }; + 67262C5E24739F1FE0011439 /* StreamingSSEEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 615F8A7C9ABCADEB215D31BD /* StreamingSSEEncoder.swift */; }; 6828CCA8B78AB40906F87CAB /* LocalModelResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D733A0D1D4AC25DDDA6C8684 /* LocalModelResolver.swift */; }; 7CD765C1E2F9F4D7504C8D09 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B629DA084A9A40E54F8EA5FA /* Assets.xcassets */; }; 80646C5066BF79BC76E1D9D7 /* ModelConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DFC212AF4359A45FBE22BA /* ModelConfig.swift */; }; 84D32315B418B5243E017350 /* ToolPromptBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16AE82A64D1D07AE3CD8D33A /* ToolPromptBuilder.swift */; }; 85FB1EB49D76A9F21E181346 /* ChatScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = C04EE8E6418EC6E9B66999B0 /* ChatScene.swift */; }; 945474365D0B3E961811909A /* MLXVLM in Frameworks */ = {isa = PBXBuildFile; productRef = D5E8E1C2DD8D8AABB4306193 /* MLXVLM */; }; + 962083CCCC4AC848E0BBBC99 /* CancellationTokenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEFF6168B2283FEC87B4BB8C /* CancellationTokenTests.swift */; }; + A146BBA70CFBEC505BDCDF0D /* ImageDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C1A89C076E717F87A60397D /* ImageDecoder.swift */; }; B13FFE238613BFBFC72E0CC8 /* ChatDocumentMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24E29065DD29C17D20B0400D /* ChatDocumentMigration.swift */; }; B1D9BC407DB7DB1489230C20 /* MonitorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4239CFF94B819C35A8D4D617 /* MonitorView.swift */; }; B5AA6E3B4BE21676226B342B /* ChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8BD93859F0291F1A3E09DA5 /* ChatViewModel.swift */; }; @@ -42,12 +45,25 @@ D666A311788375E8A061C832 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4147321383E94E9F17A0154E /* SettingsView.swift */; }; D96DDE66F76FDDA642629E17 /* APIModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A52E2C9964ADA9D841A89B /* APIModels.swift */; }; DF5C525DBD2E3153256951C1 /* SceneManagementWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA1592FD260014C4FBDB6995 /* SceneManagementWindow.swift */; }; + E199D0BB09B61AC128AB093A /* CancellationToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3489501F2F8E1BA382347CFA /* CancellationToken.swift */; }; + E92B6656C251EDA246B8F582 /* ImageDecoderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4573DC9314915F4C7963B4E /* ImageDecoderTests.swift */; }; F141B91A64F7DAD73CE2910A /* ConversationSessionCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFBB16D3AF2E61D001FD6051 /* ConversationSessionCache.swift */; }; F546CE5955ED253D8A793D5E /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = A98257123539E9E738213BFA /* MarkdownUI */; }; FAF7D4714AC6D02674920208 /* ChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4B359324B5FD8D106C74338 /* ChatMessage.swift */; }; FCD48F8C132A2B830A15EEB4 /* MLXLLM in Frameworks */ = {isa = PBXBuildFile; productRef = 3F5A4AC6DBAF7CA686ECA74E /* MLXLLM */; }; + FE4405F66873C75CD6FA19A5 /* StreamingSSEEncoderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49C383DD5224F3420EB98DB2 /* StreamingSSEEncoderTests.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 9F9E4F692B655CD8CE88479C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 938BC479816FCA8527B731F9 /* Project object */; + proxyType = 1; + remoteGlobalIDString = BCD7107EE884C9B2F4C2C40E; + remoteInfo = MLXServer; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ 0F03A123A8908714A89315FE /* SceneCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneCommands.swift; sourceTree = ""; }; 145B888FBDD4F931512C5473 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = ""; }; @@ -56,6 +72,7 @@ 24E29065DD29C17D20B0400D /* ChatDocumentMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatDocumentMigration.swift; sourceTree = ""; }; 2DC8C86D397B1FCA08E07CBD /* DownloadModalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadModalView.swift; sourceTree = ""; }; 2E2FCA55CEBEBCED78D9479A /* SaveChatCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveChatCommands.swift; sourceTree = ""; }; + 3489501F2F8E1BA382347CFA /* CancellationToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CancellationToken.swift; sourceTree = ""; }; 37FEB592E5E717F817B03151 /* SceneManagementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneManagementView.swift; sourceTree = ""; }; 386CD08DC6338F42460DFBE2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 38DFC212AF4359A45FBE22BA /* ModelConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelConfig.swift; sourceTree = ""; }; @@ -63,8 +80,11 @@ 3D08828E16B17EF02C14243E /* APIServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIServer.swift; sourceTree = ""; }; 4147321383E94E9F17A0154E /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 4239CFF94B819C35A8D4D617 /* MonitorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonitorView.swift; sourceTree = ""; }; + 49C383DD5224F3420EB98DB2 /* StreamingSSEEncoderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamingSSEEncoderTests.swift; sourceTree = ""; }; + 615F8A7C9ABCADEB215D31BD /* StreamingSSEEncoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamingSSEEncoder.swift; sourceTree = ""; }; 6B3AA91D2C7842D7366F9A41 /* ChatDocumentPackage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatDocumentPackage.swift; sourceTree = ""; }; 6EE59189918D06B8D2F588FC /* MLXServer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MLXServer.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 7C1A89C076E717F87A60397D /* ImageDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDecoder.swift; sourceTree = ""; }; 922CBDC9206737BD04AF2874 /* ModelManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelManager.swift; sourceTree = ""; }; 944C699FBB76C734C9DF2F2E /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; A4B359324B5FD8D106C74338 /* ChatMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessage.swift; sourceTree = ""; }; @@ -82,10 +102,13 @@ D7C9BAD674E29688ACE53B0B /* ChatExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatExporter.swift; sourceTree = ""; }; DB1A5E8B1C9F2BC4D262C53A /* ChatMessagesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessagesView.swift; sourceTree = ""; }; E35452B166893B25E765FF70 /* InferenceStats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InferenceStats.swift; sourceTree = ""; }; + E4573DC9314915F4C7963B4E /* ImageDecoderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDecoderTests.swift; sourceTree = ""; }; E5E6AD02CDF23BDAB64700A7 /* ChatInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInputView.swift; sourceTree = ""; }; E73B165A1822729C907791AE /* ToolCallParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolCallParser.swift; sourceTree = ""; }; EF518FEBF3A38E830E3CE1A5 /* FocusedValues.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusedValues.swift; sourceTree = ""; }; F1A52E2C9964ADA9D841A89B /* APIModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIModels.swift; sourceTree = ""; }; + F4CE2D594F7433C76169151A /* MLXServerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MLXServerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + FEFF6168B2283FEC87B4BB8C /* CancellationTokenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CancellationTokenTests.swift; sourceTree = ""; }; FFBB16D3AF2E61D001FD6051 /* ConversationSessionCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationSessionCache.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -104,6 +127,14 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 03BB61C0F16FAD47436AA178 /* MLXServerTests */ = { + isa = PBXGroup; + children = ( + 154AF0C071A7DC02EB5F6F49 /* Server */, + ); + path = MLXServerTests; + sourceTree = ""; + }; 05B1BAE308E64D2FB2E73823 /* Utilities */ = { isa = PBXGroup; children = ( @@ -126,10 +157,21 @@ path = Documents; sourceTree = ""; }; + 154AF0C071A7DC02EB5F6F49 /* Server */ = { + isa = PBXGroup; + children = ( + FEFF6168B2283FEC87B4BB8C /* CancellationTokenTests.swift */, + E4573DC9314915F4C7963B4E /* ImageDecoderTests.swift */, + 49C383DD5224F3420EB98DB2 /* StreamingSSEEncoderTests.swift */, + ); + path = Server; + sourceTree = ""; + }; 652987C2A419DBFC79E32CDE /* Products */ = { isa = PBXGroup; children = ( 6EE59189918D06B8D2F588FC /* MLXServer.app */, + F4CE2D594F7433C76169151A /* MLXServerTests.xctest */, ); name = Products; sourceTree = ""; @@ -205,7 +247,10 @@ children = ( F1A52E2C9964ADA9D841A89B /* APIModels.swift */, 3D08828E16B17EF02C14243E /* APIServer.swift */, + 3489501F2F8E1BA382347CFA /* CancellationToken.swift */, FFBB16D3AF2E61D001FD6051 /* ConversationSessionCache.swift */, + 7C1A89C076E717F87A60397D /* ImageDecoder.swift */, + 615F8A7C9ABCADEB215D31BD /* StreamingSSEEncoder.swift */, E73B165A1822729C907791AE /* ToolCallParser.swift */, 16AE82A64D1D07AE3CD8D33A /* ToolPromptBuilder.swift */, ); @@ -216,6 +261,7 @@ isa = PBXGroup; children = ( 6816BF8EF7C92384DD7C9177 /* MLXServer */, + 03BB61C0F16FAD47436AA178 /* MLXServerTests */, 652987C2A419DBFC79E32CDE /* Products */, ); sourceTree = ""; @@ -246,6 +292,24 @@ productReference = 6EE59189918D06B8D2F588FC /* MLXServer.app */; productType = "com.apple.product-type.application"; }; + CE11F8C258BB944F38A5840D /* MLXServerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = A2168D037766ED36A199C6F7 /* Build configuration list for PBXNativeTarget "MLXServerTests" */; + buildPhases = ( + 6DEBF8BBA4F6DB333E0C55B0 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + 8870DD8F1917C831FD4FD595 /* PBXTargetDependency */, + ); + name = MLXServerTests; + packageProductDependencies = ( + ); + productName = MLXServerTests; + productReference = F4CE2D594F7433C76169151A /* MLXServerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -276,6 +340,7 @@ projectRoot = ""; targets = ( BCD7107EE884C9B2F4C2C40E /* MLXServer */, + CE11F8C258BB944F38A5840D /* MLXServerTests */, ); }; /* End PBXProject section */ @@ -292,12 +357,23 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 6DEBF8BBA4F6DB333E0C55B0 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 962083CCCC4AC848E0BBBC99 /* CancellationTokenTests.swift in Sources */, + E92B6656C251EDA246B8F582 /* ImageDecoderTests.swift in Sources */, + FE4405F66873C75CD6FA19A5 /* StreamingSSEEncoderTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; BC03844286F51DFAEF96B823 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( D96DDE66F76FDDA642629E17 /* APIModels.swift in Sources */, 50DD129CCF2843482DEC3B96 /* APIServer.swift in Sources */, + E199D0BB09B61AC128AB093A /* CancellationToken.swift in Sources */, 2E3A02DF9C6A5109E532D5E2 /* ChatDocumentController.swift in Sources */, 1A8833E3CCD3289C95E282A2 /* ChatDocumentManifest.swift in Sources */, B13FFE238613BFBFC72E0CC8 /* ChatDocumentMigration.swift in Sources */, @@ -312,6 +388,7 @@ F141B91A64F7DAD73CE2910A /* ConversationSessionCache.swift in Sources */, C07A377244DCD67F4FE709FE /* DownloadModalView.swift in Sources */, 4DC033E45880B2948B47DEB1 /* FocusedValues.swift in Sources */, + A146BBA70CFBEC505BDCDF0D /* ImageDecoder.swift in Sources */, 2D08769282BD71C170DB0943 /* InferenceStats.swift in Sources */, 6828CCA8B78AB40906F87CAB /* LocalModelResolver.swift in Sources */, 50B6861FF8610B3ED4FFAD9D /* MLXServerApp.swift in Sources */, @@ -328,6 +405,7 @@ CFEE79815DFB80E51FE3745A /* SceneStore.swift in Sources */, D666A311788375E8A061C832 /* SettingsView.swift in Sources */, 621B7E4382199AC1378F5F9C /* StatusBarView.swift in Sources */, + 67262C5E24739F1FE0011439 /* StreamingSSEEncoder.swift in Sources */, 189362AAE2CDE5D4B3428334 /* ToolCallParser.swift in Sources */, 84D32315B418B5243E017350 /* ToolPromptBuilder.swift in Sources */, ); @@ -335,7 +413,49 @@ }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 8870DD8F1917C831FD4FD595 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = BCD7107EE884C9B2F4C2C40E /* MLXServer */; + targetProxy = 9F9E4F692B655CD8CE88479C /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ + 18921C5B777D8B7FEF662D6F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + COMBINE_HIDPI_IMAGES = YES; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.mlxserver.MLXServerTests; + SDKROOT = macosx; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MLX Server.app/Contents/MacOS/MLX Server"; + }; + name = Release; + }; + 2B83417701A93BF554428C56 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + COMBINE_HIDPI_IMAGES = YES; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.mlxserver.MLXServerTests; + SDKROOT = macosx; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MLX Server.app/Contents/MacOS/MLX Server"; + }; + name = Debug; + }; 6C0C08FC4653A138A768ECF0 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -524,6 +644,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + A2168D037766ED36A199C6F7 /* Build configuration list for PBXNativeTarget "MLXServerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2B83417701A93BF554428C56 /* Debug */, + 18921C5B777D8B7FEF662D6F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/MLXServer.xcodeproj/xcshareddata/xcschemes/MLXServer.xcscheme b/MLXServer.xcodeproj/xcshareddata/xcschemes/MLXServer.xcscheme new file mode 100644 index 0000000..d33e203 --- /dev/null +++ b/MLXServer.xcodeproj/xcshareddata/xcschemes/MLXServer.xcscheme @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MLXServer/ContentView.swift b/MLXServer/ContentView.swift index 94b5a42..bf05743 100644 --- a/MLXServer/ContentView.swift +++ b/MLXServer/ContentView.swift @@ -15,6 +15,10 @@ struct ContentView: View { @State private var documentErrorMessage: String? @State private var exportErrorMessage: String? + private var isRunningTests: Bool { + ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil + } + var body: some View { exportedContent } @@ -30,11 +34,11 @@ struct ContentView: View { delegate.chatViewModel = vm } // Auto-start API server if configured - if Preferences.apiAutoStart { + if Preferences.apiAutoStart && !isRunningTests { vm.startAPIServer() } // Restore autosaved session if no document is being opened - if !documentController.hasPendingOpenRequests { + if !documentController.hasPendingOpenRequests && !isRunningTests { Task { await vm.restoreFromAutosave() } diff --git a/MLXServer/Server/APIServer.swift b/MLXServer/Server/APIServer.swift index bd162cb..669a3ff 100644 --- a/MLXServer/Server/APIServer.swift +++ b/MLXServer/Server/APIServer.swift @@ -1,4 +1,3 @@ -import AppKit import Foundation import MLXLMCommon import Network @@ -288,7 +287,7 @@ final class APIServer { var messageImages: [UserInput.Image] = [] var messageImageBytes = 0 for urlString in imageURLs { - if let decoded = decodeBase64Image(urlString) { + if let decoded = ImageDecoder.decode(urlString) { messageImages.append(decoded.image) messageImageBytes += decoded.estimatedBytes } @@ -448,28 +447,6 @@ final class APIServer { modelManager.touchActivity() } - /// Decode a base64 data URI (data:image/png;base64,...) into a UserInput.Image. - private func decodeBase64Image(_ urlString: String) -> DecodedImage? { - // Handle data URIs: data:image/png;base64, - let base64String: String - if urlString.hasPrefix("data:") { - guard let commaIndex = urlString.firstIndex(of: ",") else { return nil } - base64String = String(urlString[urlString.index(after: commaIndex)...]) - } else { - // Could be a plain base64 string - base64String = urlString - } - - guard let data = Data(base64Encoded: base64String), - let nsImage = NSImage(data: data), - let cgImage = nsImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else { - return nil - } - - let estimatedBytes = max(data.count, cgImage.width * cgImage.height * 4) - return DecodedImage(image: .ciImage(CIImage(cgImage: cgImage)), estimatedBytes: estimatedBytes) - } - // MARK: - Non-streaming response private func handleNonStreamingResponse( @@ -887,11 +864,6 @@ final class APIServer { } } -private struct DecodedImage { - let image: UserInput.Image - let estimatedBytes: Int -} - // MARK: - HTTP request parser private struct HTTPRequest { diff --git a/MLXServer/Server/CancellationToken.swift b/MLXServer/Server/CancellationToken.swift new file mode 100644 index 0000000..999049e --- /dev/null +++ b/MLXServer/Server/CancellationToken.swift @@ -0,0 +1,14 @@ +import os + +/// Thread-safe cancellation flag for cooperative stream shutdown. +final class CancellationToken: @unchecked Sendable { + private let lock = OSAllocatedUnfairLock(initialState: false) + + var isCancelled: Bool { + lock.withLock { $0 } + } + + func cancel() { + lock.withLock { $0 = true } + } +} \ No newline at end of file diff --git a/MLXServer/Server/ImageDecoder.swift b/MLXServer/Server/ImageDecoder.swift new file mode 100644 index 0000000..a1f12bb --- /dev/null +++ b/MLXServer/Server/ImageDecoder.swift @@ -0,0 +1,31 @@ +import AppKit +import CoreImage +import Foundation +import MLXLMCommon + +/// Extracted from APIServer — decodes data URIs to UserInput.Image. +enum ImageDecoder { + struct DecodedImage { + let image: UserInput.Image + let estimatedBytes: Int + } + + static func decode(_ urlString: String) -> DecodedImage? { + let base64String: String + if urlString.hasPrefix("data:") { + guard let commaIndex = urlString.firstIndex(of: ",") else { return nil } + base64String = String(urlString[urlString.index(after: commaIndex)...]) + } else { + base64String = urlString + } + + guard let data = Data(base64Encoded: base64String), + let nsImage = NSImage(data: data), + let cgImage = nsImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + return nil + } + + let estimatedBytes = max(data.count, cgImage.width * cgImage.height * 4) + return DecodedImage(image: .ciImage(CIImage(cgImage: cgImage)), estimatedBytes: estimatedBytes) + } +} \ No newline at end of file diff --git a/MLXServer/Server/StreamingSSEEncoder.swift b/MLXServer/Server/StreamingSSEEncoder.swift new file mode 100644 index 0000000..8c35eed --- /dev/null +++ b/MLXServer/Server/StreamingSSEEncoder.swift @@ -0,0 +1,72 @@ +import Foundation + +/// Pre-computes static JSON parts for SSE streaming. +/// Only the dynamic delta payload is serialized per token. +struct StreamingSSEEncoder: Sendable { + private let requestId: String + private let created: Int + private let modelName: String + + init(requestId: String, created: Int, modelName: String) { + self.requestId = requestId + self.created = created + self.modelName = modelName + } + + func encodeContentDelta(_ text: String) -> Data { + Self.encodeChunk( + APIChatCompletionChunk( + id: requestId, + object: "chat.completion.chunk", + created: created, + model: modelName, + choices: [ + APIStreamChoice( + index: 0, + delta: APIDeltaMessage(role: nil, content: text, tool_calls: nil), + finish_reason: nil + ) + ], + usage: nil + ) + ) + } + + func encodeRoleDelta(_ role: String) -> Data { + Self.encodeChunk( + APIChatCompletionChunk( + id: requestId, + object: "chat.completion.chunk", + created: created, + model: modelName, + choices: [ + APIStreamChoice( + index: 0, + delta: APIDeltaMessage(role: role, content: nil, tool_calls: nil), + finish_reason: nil + ) + ], + usage: nil + ) + ) + } + + static func encodeFinalChunk(_ chunk: APIChatCompletionChunk) -> Data { + encodeChunk(chunk) + } + + private static func encodeChunk(_ chunk: APIChatCompletionChunk) -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + + guard let json = try? encoder.encode(chunk) else { + return Data("data: {}\n\n".utf8) + } + + var data = Data(capacity: json.count + 8) + data.append(Data("data: ".utf8)) + data.append(json) + data.append(Data("\n\n".utf8)) + return data + } +} \ No newline at end of file diff --git a/MLXServerTests/Server/CancellationTokenTests.swift b/MLXServerTests/Server/CancellationTokenTests.swift new file mode 100644 index 0000000..9f8e06f --- /dev/null +++ b/MLXServerTests/Server/CancellationTokenTests.swift @@ -0,0 +1,18 @@ +import XCTest +@testable import MLX_Server + +final class CancellationTokenTests: XCTestCase { + func testStartsNotCancelled() { + let token = CancellationToken() + + XCTAssertFalse(token.isCancelled) + } + + func testCancelSetsFlag() { + let token = CancellationToken() + + token.cancel() + + XCTAssertTrue(token.isCancelled) + } +} \ No newline at end of file diff --git a/MLXServerTests/Server/ImageDecoderTests.swift b/MLXServerTests/Server/ImageDecoderTests.swift new file mode 100644 index 0000000..d60e371 --- /dev/null +++ b/MLXServerTests/Server/ImageDecoderTests.swift @@ -0,0 +1,20 @@ +import XCTest +@testable import MLX_Server + +final class ImageDecoderTests: XCTestCase { + private let onePixelPNGBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lRyXWQAAAABJRU5ErkJggg==" + + func testDecodeDataURI() { + let image = ImageDecoder.decode("data:image/png;base64,\(onePixelPNGBase64)") + + XCTAssertNotNil(image) + XCTAssertGreaterThanOrEqual(image?.estimatedBytes ?? 0, 4) + } + + func testDecodePlainBase64() { + let image = ImageDecoder.decode(onePixelPNGBase64) + + XCTAssertNotNil(image) + XCTAssertGreaterThanOrEqual(image?.estimatedBytes ?? 0, 4) + } +} \ No newline at end of file diff --git a/MLXServerTests/Server/StreamingSSEEncoderTests.swift b/MLXServerTests/Server/StreamingSSEEncoderTests.swift new file mode 100644 index 0000000..80acfa2 --- /dev/null +++ b/MLXServerTests/Server/StreamingSSEEncoderTests.swift @@ -0,0 +1,82 @@ +import XCTest +@testable import MLX_Server + +final class StreamingSSEEncoderTests: XCTestCase { + func testEncodeContentDeltaMatchesJSONEncoderOutput() throws { + let encoder = StreamingSSEEncoder(requestId: "chatcmpl-test", created: 1_234_567, modelName: "qwen\"model") + let text = "line 1\nline 2\t\"quoted\"\\slash" + + let actual = encoder.encodeContentDelta(text) + let expected = try baselineData( + for: APIChatCompletionChunk( + id: "chatcmpl-test", + object: "chat.completion.chunk", + created: 1_234_567, + model: "qwen\"model", + choices: [ + APIStreamChoice( + index: 0, + delta: APIDeltaMessage(role: nil, content: text, tool_calls: nil), + finish_reason: nil + ) + ], + usage: nil + ) + ) + + XCTAssertEqual(actual, expected) + } + + func testEncodeRoleDeltaMatchesJSONEncoderOutput() throws { + let encoder = StreamingSSEEncoder(requestId: "chatcmpl-role", created: 99, modelName: "gemma") + + let actual = encoder.encodeRoleDelta("assistant") + let expected = try baselineData( + for: APIChatCompletionChunk( + id: "chatcmpl-role", + object: "chat.completion.chunk", + created: 99, + model: "gemma", + choices: [ + APIStreamChoice( + index: 0, + delta: APIDeltaMessage(role: "assistant", content: nil, tool_calls: nil), + finish_reason: nil + ) + ], + usage: nil + ) + ) + + XCTAssertEqual(actual, expected) + } + + func testEncodeFinalChunkMatchesBaseline() throws { + let chunk = APIChatCompletionChunk( + id: "chatcmpl-final", + object: "chat.completion.chunk", + created: 7, + model: "gemma", + choices: [ + APIStreamChoice( + index: 0, + delta: APIDeltaMessage(role: nil, content: nil, tool_calls: nil), + finish_reason: "stop" + ) + ], + usage: APIUsageInfo(prompt_tokens: 10, completion_tokens: 3, total_tokens: 13) + ) + + XCTAssertEqual(StreamingSSEEncoder.encodeFinalChunk(chunk), try baselineData(for: chunk)) + } + + private func baselineData(for chunk: APIChatCompletionChunk) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let json = try encoder.encode(chunk) + var data = Data("data: ".utf8) + data.append(json) + data.append(Data("\n\n".utf8)) + return data + } +} \ No newline at end of file diff --git a/docs/session-cache-upgrade.md b/docs/session-cache-upgrade.md index 3fb4358..065e047 100644 --- a/docs/session-cache-upgrade.md +++ b/docs/session-cache-upgrade.md @@ -2558,9 +2558,9 @@ Each step should be independently buildable and testable. ### Phase 1: Foundation (no behavior change yet) -1. **`CancellationToken.swift`** — Standalone utility, no dependencies. Write + unit test. -2. **`ImageDecoder.swift`** — Extract from APIServer. Mechanical move. -3. **`StreamingSSEEncoder.swift`** — Standalone, testable in isolation. Verify JSON output matches current `JSONEncoder` output. +1. [x] **`CancellationToken.swift`** — Standalone utility, no dependencies. Write + unit test. +2. [x] **`ImageDecoder.swift`** — Extract from APIServer. Mechanical move. +3. [x] **`StreamingSSEEncoder.swift`** — Standalone, testable in isolation. Verify JSON output matches current `JSONEncoder` output. ### Phase 2: Core Engine diff --git a/project.yml b/project.yml index e28c9c2..6201c08 100644 --- a/project.yml +++ b/project.yml @@ -42,3 +42,25 @@ targets: product: MLXLMCommon - package: MarkdownUI product: MarkdownUI + MLXServerTests: + type: bundle.unit-test + platform: macOS + sources: + - MLXServerTests + settings: + base: + GENERATE_INFOPLIST_FILE: "YES" + TEST_HOST: "$(BUILT_PRODUCTS_DIR)/MLX Server.app/Contents/MacOS/MLX Server" + BUNDLE_LOADER: "$(TEST_HOST)" + dependencies: + - target: MLXServer + +schemes: + MLXServer: + build: + targets: + MLXServer: all + MLXServerTests: [test] + test: + targets: + - MLXServerTests diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..2bf6397 --- /dev/null +++ b/test.sh @@ -0,0 +1,28 @@ +#!/bin/bash +set -euo pipefail + +PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)" +BUILD_DIR="$PROJECT_DIR/build" +CONFIG="${1:-Debug}" +APP_NAME="MLX Server" +DESTINATION="${TEST_DESTINATION:-platform=macOS,arch=arm64}" + +echo "==> Testing $APP_NAME ($CONFIG)" + +# Regenerate Xcode project from project.yml (picks up any new/removed files) +if command -v xcodegen &>/dev/null; then + xcodegen generate --spec "$PROJECT_DIR/project.yml" --project "$PROJECT_DIR" 2>&1 | grep -v '^$' +fi + +# Run tests — filter to test progress, app warnings, build failures, and final result +xcodebuild \ + -project "$PROJECT_DIR/MLXServer.xcodeproj" \ + -scheme MLXServer \ + -destination "$DESTINATION" \ + -configuration "$CONFIG" \ + SYMROOT="$BUILD_DIR" \ + test 2>&1 | \ + grep -E "(Test Suite|Test Case|Executed [0-9]+ tests|Testing started|Testing failed|Testing passed|error:|warning:.*MLXServer/|\*\* TEST|BUILD )" + +echo "" +echo "==> Tests passed" \ No newline at end of file