diff --git a/Cryptohopper-iOS-SDK.xcodeproj/project.pbxproj b/Cryptohopper-iOS-SDK.xcodeproj/project.pbxproj index 2f75c2e..23d5cc4 100644 --- a/Cryptohopper-iOS-SDK.xcodeproj/project.pbxproj +++ b/Cryptohopper-iOS-SDK.xcodeproj/project.pbxproj @@ -7,7 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + 0CCD96FB134CBEA7B93F5E8A /* HopperAPIAskAIStreamingRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5587C9978A676EF22081303D /* HopperAPIAskAIStreamingRequest.swift */; }; + 375AE192EF5DDEF2CA81F7ED /* AskAIStreamParserSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7933BD30DEFF47AD510CCDC9 /* AskAIStreamParserSpec.swift */; }; 59576957B76E88660EDCD9F6 /* Pods_Cryptohopper_iOS_SDKTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F006D690718FB3AD4ACF2C61 /* Pods_Cryptohopper_iOS_SDKTests.framework */; }; + 631307BD7693BAAF7B72B206 /* AskAIStreamEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 211C9DED44706861D3FE369A /* AskAIStreamEvent.swift */; }; D0021D3C2718789F0050D7CC /* HopperAPIAuthWithCodeRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0021D3B2718789F0050D7CC /* HopperAPIAuthWithCodeRequest.swift */; }; D002BE81292B9F5300E74B6A /* HopperAPIAuthDeviceResendEmailRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D002BE80292B9F5300E74B6A /* HopperAPIAuthDeviceResendEmailRequest.swift */; }; D002BE83292B9F6C00E74B6A /* HopperAPIAuthDeviceCheckIfAuthorizedRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D002BE82292B9F6C00E74B6A /* HopperAPIAuthDeviceCheckIfAuthorizedRequest.swift */; }; @@ -449,6 +452,7 @@ D0F8C511283E5E0C006A8CE7 /* HopperAPIV2OnboardingCreateRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F8C510283E5E0C006A8CE7 /* HopperAPIV2OnboardingCreateRequest.swift */; }; D0F8C513283E5F1B006A8CE7 /* HopperAPIV2OnboardingInitResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F8C512283E5F1B006A8CE7 /* HopperAPIV2OnboardingInitResponse.swift */; }; D0F8C516283E692F006A8CE7 /* OnboardingAPIV2Spec.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F8C515283E692F006A8CE7 /* OnboardingAPIV2Spec.swift */; }; + D6C357EB6E8B7C3E1D33FC41 /* AskAIStreamParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8CE3F96121E01342108AEAE /* AskAIStreamParser.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -485,8 +489,12 @@ /* Begin PBXFileReference section */ 1A1FB7E16BBDF708C509A103 /* Pods-Cryptohopper-iOS-SDKTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Cryptohopper-iOS-SDKTests.debug.xcconfig"; path = "Target Support Files/Pods-Cryptohopper-iOS-SDKTests/Pods-Cryptohopper-iOS-SDKTests.debug.xcconfig"; sourceTree = ""; }; + 211C9DED44706861D3FE369A /* AskAIStreamEvent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AskAIStreamEvent.swift; sourceTree = ""; }; + 5587C9978A676EF22081303D /* HopperAPIAskAIStreamingRequest.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HopperAPIAskAIStreamingRequest.swift; sourceTree = ""; }; 59BF9CCB664C3FC5A211DB13 /* Pods_Cryptohopper_iOS_SDK.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Cryptohopper_iOS_SDK.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 7933BD30DEFF47AD510CCDC9 /* AskAIStreamParserSpec.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AskAIStreamParserSpec.swift; sourceTree = ""; }; ACA94F67F40DFEC2AE6EB5E6 /* Pods-Cryptohopper-iOS-SDK.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Cryptohopper-iOS-SDK.debug.xcconfig"; path = "Target Support Files/Pods-Cryptohopper-iOS-SDK/Pods-Cryptohopper-iOS-SDK.debug.xcconfig"; sourceTree = ""; }; + B8CE3F96121E01342108AEAE /* AskAIStreamParser.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AskAIStreamParser.swift; sourceTree = ""; }; D0021D3B2718789F0050D7CC /* HopperAPIAuthWithCodeRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HopperAPIAuthWithCodeRequest.swift; sourceTree = ""; }; D002BE80292B9F5300E74B6A /* HopperAPIAuthDeviceResendEmailRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HopperAPIAuthDeviceResendEmailRequest.swift; sourceTree = ""; }; D002BE82292B9F6C00E74B6A /* HopperAPIAuthDeviceCheckIfAuthorizedRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HopperAPIAuthDeviceCheckIfAuthorizedRequest.swift; sourceTree = ""; }; @@ -964,6 +972,15 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 1591570A537B53DD3C26F25D /* AskAI */ = { + isa = PBXGroup; + children = ( + C4A9C4CBF2CB789F5B2682AC /* Streaming */, + ); + name = AskAI; + path = AskAI; + sourceTree = ""; + }; 1AF7BFB7D52C96D9AE8AE457 /* Pods */ = { isa = PBXGroup; children = ( @@ -984,6 +1001,17 @@ name = Frameworks; sourceTree = ""; }; + C4A9C4CBF2CB789F5B2682AC /* Streaming */ = { + isa = PBXGroup; + children = ( + 211C9DED44706861D3FE369A /* AskAIStreamEvent.swift */, + B8CE3F96121E01342108AEAE /* AskAIStreamParser.swift */, + 5587C9978A676EF22081303D /* HopperAPIAskAIStreamingRequest.swift */, + ); + name = Streaming; + path = Streaming; + sourceTree = ""; + }; D0021D3A2718788F0050D7CC /* AuthWithCode */ = { isa = PBXGroup; children = ( @@ -1413,6 +1441,7 @@ D0231D1025514BE40057A293 /* Subscription */, D0AC8A11285B570B0071B90E /* CheckPasswordReset */, D0AC8A10285B56FC0071B90E /* Check2FAReset */, + 1591570A537B53DD3C26F25D /* AskAI */, ); path = User; sourceTree = ""; @@ -2660,6 +2689,7 @@ isa = PBXGroup; children = ( D047A8BD2779CAC4007D5485 /* AISpec.swift */, + 7933BD30DEFF47AD510CCDC9 /* AskAIStreamParserSpec.swift */, ); path = AI; sourceTree = ""; @@ -4685,6 +4715,9 @@ D0AF9E092625A3AC001FFDF3 /* AnyCodable.swift in Sources */, D0231ECA2552D3A80057A293 /* HopperAPIGetTradingPairOfExchangeRequest.swift in Sources */, D0231F5B2552F0AE0057A293 /* HopperAPIGetStrategyByIdResponse.swift in Sources */, + 631307BD7693BAAF7B72B206 /* AskAIStreamEvent.swift in Sources */, + D6C357EB6E8B7C3E1D33FC41 /* AskAIStreamParser.swift in Sources */, + 0CCD96FB134CBEA7B93F5E8A /* HopperAPIAskAIStreamingRequest.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4709,6 +4742,7 @@ D0F8C501283E0D40006A8CE7 /* ExchangeAPIV2Spec.swift in Sources */, D09CD4942840C83B009B503F /* TournamentAPIV2Spec.swift in Sources */, D0F3D4EF25655F020036B130 /* UserSpec.swift in Sources */, + 375AE192EF5DDEF2CA81F7ED /* AskAIStreamParserSpec.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Cryptohopper-iOS-SDK/API/User/AskAI/PerformAskAI/HopperAPIPerformAskAIResponse.swift b/Cryptohopper-iOS-SDK/API/User/AskAI/PerformAskAI/HopperAPIPerformAskAIResponse.swift index 7c11a3d..f7825fc 100644 --- a/Cryptohopper-iOS-SDK/API/User/AskAI/PerformAskAI/HopperAPIPerformAskAIResponse.swift +++ b/Cryptohopper-iOS-SDK/API/User/AskAI/PerformAskAI/HopperAPIPerformAskAIResponse.swift @@ -6,12 +6,24 @@ import Foundation class HopperAPIPerformAskAIResponse: Codable { - + var answer: AskAIAnswer? - + private enum CodingKeys: String, CodingKey { case answer = "data" } - + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + if let object = try? container.decodeIfPresent(AskAIAnswer.self, forKey: .answer) { + answer = object + } else if let text = try? container.decodeIfPresent(String.self, forKey: .answer) { + // Suggestion answers come back as a bare string in `data` + // (askAiSuggestion in the monolith) instead of the object the + // typed-question path returns. + answer = AskAIAnswer(content: text) + } + } + } diff --git a/Cryptohopper-iOS-SDK/API/User/AskAI/Streaming/AskAIStreamEvent.swift b/Cryptohopper-iOS-SDK/API/User/AskAI/Streaming/AskAIStreamEvent.swift new file mode 100644 index 0000000..197a380 --- /dev/null +++ b/Cryptohopper-iOS-SDK/API/User/AskAI/Streaming/AskAIStreamEvent.swift @@ -0,0 +1,13 @@ +// +// AskAIStreamEvent.swift +// Cryptohopper-iOS-SDK +// + +import Foundation + +/// One normalized event from the Ask AI SSE stream. +public enum AskAIStreamEvent { + case delta(text: String) + case done(runId: String?, sessionId: String?) + case error(message: String) +} diff --git a/Cryptohopper-iOS-SDK/API/User/AskAI/Streaming/AskAIStreamParser.swift b/Cryptohopper-iOS-SDK/API/User/AskAI/Streaming/AskAIStreamParser.swift new file mode 100644 index 0000000..d1ae925 --- /dev/null +++ b/Cryptohopper-iOS-SDK/API/User/AskAI/Streaming/AskAIStreamParser.swift @@ -0,0 +1,101 @@ +// +// AskAIStreamParser.swift +// Cryptohopper-iOS-SDK +// + +import Foundation + +/// Incremental parser for the Ask AI SSE stream. +/// Feed raw body chunks as they arrive; complete frames (separated by a +/// blank line) are parsed into events. Buffers as Data so multi-byte +/// UTF-8 characters split across chunks survive. +public final class AskAIStreamParser { + + private var buffer = Data() + + public init() {} + + public func feed(_ chunk: Data) -> [AskAIStreamEvent] { + buffer.append(chunk) + var events: [AskAIStreamEvent] = [] + let lflf = Data("\n\n".utf8) + let crlfcrlf = Data("\r\n\r\n".utf8) + while true { + let lfRange = buffer.range(of: lflf) + let crRange = buffer.range(of: crlfcrlf) + let separator: Range + switch (lfRange, crRange) { + case (nil, nil): + return events + case (let lf?, nil): + separator = lf + case (nil, let cr?): + separator = cr + case (let lf?, let cr?): + separator = lf.lowerBound < cr.lowerBound ? lf : cr + } + let frameData = buffer.subdata(in: buffer.startIndex.. [AskAIStreamEvent] { + defer { buffer.removeAll() } + guard !buffer.isEmpty, let event = Self.parseFrame(buffer) else { return [] } + return [event] + } + + static func parseFrame(_ frameData: Data) -> AskAIStreamEvent? { + guard let raw = String(data: frameData, encoding: .utf8) else { return nil } + // Normalize CRLF to LF before splitting: Swift's String treats "\r\n" + // as a single Character (grapheme cluster), so splitting on the "\n" + // Character alone would never break at a CRLF line ending. + let normalized = raw.replacingOccurrences(of: "\r\n", with: "\n") + let lines = normalized.split(separator: "\n", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: CharacterSet(charactersIn: "\r")) } + + var eventName: String? + var dataLines: [String] = [] + for line in lines { + if line.hasPrefix("event:") { + if eventName == nil { + eventName = String(line.dropFirst("event:".count)).trimmingCharacters(in: .whitespaces) + } + } else if line.hasPrefix("data:") { + var value = String(line.dropFirst("data:".count)) + if value.hasPrefix(" ") { value.removeFirst() } + dataLines.append(value) + } + // Lines starting with ":" are comments (pings); ignore. + } + + let joined = dataLines.joined(separator: "\n") + guard let jsonData = joined.data(using: .utf8), + let payload = (try? JSONSerialization.jsonObject(with: jsonData)) as? [String: Any] else { + return nil + } + + let kind = eventName ?? (payload["type"] as? String) + switch kind { + case "delta": + guard let content = payload["content"] as? String else { return nil } + return .delta(text: content) + case "done", "final": + if let errorMessage = payload["error"] as? String, !errorMessage.isEmpty { + return .error(message: errorMessage) + } + return .done(runId: payload["run_id"] as? String, + sessionId: payload["session_id"] as? String) + case "error": + return .error(message: (payload["message"] as? String) ?? "Unknown stream error") + default: + // conversation / tool_call / tool_result / metadata / unknown: ignore. + return nil + } + } +} diff --git a/Cryptohopper-iOS-SDK/API/User/AskAI/Streaming/HopperAPIAskAIStreamingRequest.swift b/Cryptohopper-iOS-SDK/API/User/AskAI/Streaming/HopperAPIAskAIStreamingRequest.swift new file mode 100644 index 0000000..2e16a5b --- /dev/null +++ b/Cryptohopper-iOS-SDK/API/User/AskAI/Streaming/HopperAPIAskAIStreamingRequest.swift @@ -0,0 +1,201 @@ +// +// HopperAPIAskAIStreamingRequest.swift +// Cryptohopper-iOS-SDK +// + +import Foundation +import UIKit + +/// Calls POST /v1/user/askai and handles BOTH response shapes: +/// - text/event-stream (AI Gateway active): incremental AskAIStreamEvents +/// - application/json (legacy path): decoded AskAIAnswer in the completion +final class HopperAPIAskAIStreamingRequest: NSObject, URLSessionDataDelegate { + + private let page: String + private let question: String + private let onEvent: (AskAIStreamEvent) -> Void + private let completion: (Result) -> Void + + private let parser = AskAIStreamParser() + private var urlSession: URLSession? + private var isEventStream = false + private var jsonBody = Data() + private var completed = false + + init(page: String, + question: String, + onEvent: @escaping (AskAIStreamEvent) -> Void, + completion: @escaping (Result) -> Void) { + self.page = page + self.question = question + self.onEvent = onEvent + self.completion = completion + super.init() + } + + func start() { + // Capture self STRONGLY here: the only strong reference to this request + // lives in the caller's local variable, which goes out of scope as soon + // as `start()` returns. checkAuthentication's refresh path is async, so + // a weak capture would let this object deallocate before the callback + // runs, silently dropping the request. This closure keeps it alive + // until startRequest(accessToken:) hands retention to the URLSession + // delegate, or the fail path calls finish(...). + HopperAPISessionManager.shared.checkAuthentication(onSuccess: { + guard let accessToken = HopperAPISessionManager.shared.session?.accessToken else { + self.finish(.failure(HopperError.missingAccessToken)) + return + } + self.startRequest(accessToken: accessToken) + }, onFail: { error in + self.finish(.failure(error)) + }) + } + + private func startRequest(accessToken: String) { + var components = URLComponents() + let config = HopperAPIConfigurationManager.shared.config + components.scheme = config.urlScheme + components.host = config.host + components.path = "/v1/user/askai" + guard let url = components.url else { + finish(.failure(HopperError.missingUrl)) + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("text/event-stream, application/json", forHTTPHeaderField: "Accept") + request.setValue("iOS", forHTTPHeaderField: "Platform") + request.setValue(UIDevice.current.identifierForVendor?.uuidString ?? "", forHTTPHeaderField: "DeviceId") + request.setValue(config.apiBasicValidationValue, forHTTPHeaderField: config.apiBasicValidationKey) + request.setValue(accessToken, forHTTPHeaderField: "access-token") + request.httpBody = try? JSONSerialization.data(withJSONObject: ["page": page, "question": question]) + + let sessionConfig = URLSessionConfiguration.default + sessionConfig.timeoutIntervalForRequest = 120 // gateway upstream timeout is 90s + let session = URLSession(configuration: sessionConfig, delegate: self, delegateQueue: nil) + urlSession = session + session.dataTask(with: request).resume() + } + + // MARK: - URLSessionDataDelegate + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, + didReceive response: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + let contentType = Self.headerValue("Content-Type", in: response)?.lowercased() ?? "" + isEventStream = contentType.contains("text/event-stream") + completionHandler(.allow) + } + + /// SSE bodies start with "event:", "data:" or a ":" comment line + /// (optionally after whitespace); JSON starts with "{" or "[". + private static func looksLikeEventStream(_ body: Data) -> Bool { + guard let prefix = String(data: body.prefix(32), encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines), !prefix.isEmpty else { return false } + return prefix.hasPrefix("event:") || prefix.hasPrefix("data:") || prefix.hasPrefix(":") + } + + /// Case-insensitive header lookup. `HTTPURLResponse.value(forHTTPHeaderField:)` + /// is iOS 13+ only; this SDK's deployment target is iOS 12, so headers are + /// read directly from `allHeaderFields` instead. + private static func headerValue(_ name: String, in response: URLResponse?) -> String? { + guard let httpResponse = response as? HTTPURLResponse else { return nil } + for (key, value) in httpResponse.allHeaderFields { + if let key = key as? String, key.caseInsensitiveCompare(name) == .orderedSame { + return value as? String + } + } + return nil + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + if !isEventStream && jsonBody.isEmpty { + // The public API router sets a global application/json header + // before the askai SSE branch runs, so a stream can arrive + // mislabeled. Sniff the first chunk: SSE bodies start with + // "event:", "data:" or a ":" comment line; JSON never does. + if Self.looksLikeEventStream(data) { + isEventStream = true + } + } + if isEventStream { + emit(parser.feed(data)) + } else { + jsonBody.append(data) + } + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + defer { urlSession?.finishTasksAndInvalidate() } + if let error = error { + finish(.failure(error)) + return + } + // Belt and braces: a fully buffered, mislabeled stream (or one whose + // first chunk was too short to classify) is still parseable here. + if !isEventStream && Self.looksLikeEventStream(jsonBody) { + isEventStream = true + emit(parser.feed(jsonBody)) + } + if isEventStream { + emit(parser.flush()) + finish(.success(nil)) + return + } + // JSON fallback: decode like HopperAPIPerformAskAIRequest does. + let statusCode = (task.response as? HTTPURLResponse)?.statusCode ?? 0 + do { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .secondsSince1970 + if statusCode >= 200 && statusCode < 300 { + // Mirror HopperAPIRequest.startRequest's responseOK check: a 2xx + // body can still carry a common error payload with status 402. + if let errCode = try? decoder.decode(HopperCommonMessageResponse.self, from: jsonBody), + errCode.error != nil, errCode.status == 402 { + triggerDeviceUnauthorized() + finish(.failure(HopperError.DeviceUnauthorized)) + return + } + let response = try decoder.decode(HopperAPIPerformAskAIResponse.self, from: jsonBody) + finish(.success(response.answer)) + } else { + if statusCode == 402 { + triggerDeviceUnauthorized() + finish(.failure(HopperError.DeviceUnauthorized)) + return + } + let apiError = try decoder.decode(HopperAPIError.self, from: jsonBody) + finish(.failure(apiError.error)) + } + } catch { + finish(.failure(error)) + } + } + + // MARK: - Helpers + + /// Mirrors HopperAPIRequest.triggerDeviceUnauthorized(): posts the same + /// notification so observers (e.g. a global logout handler) react the + /// same way regardless of which request path hit the 402. + private func triggerDeviceUnauthorized() { + NotificationCenter.default.post(name: NSNotification.Name(rawValue: "CH_DEVICE_UNAUTHORIZED"), object: nil) + } + + private func emit(_ events: [AskAIStreamEvent]) { + guard !events.isEmpty else { return } + DispatchQueue.main.async { + events.forEach { self.onEvent($0) } + } + } + + private func finish(_ result: Result) { + guard !completed else { return } + completed = true + DispatchQueue.main.async { + self.completion(result) + } + } +} diff --git a/Cryptohopper-iOS-SDK/APIContext/CryptohopperUser.swift b/Cryptohopper-iOS-SDK/APIContext/CryptohopperUser.swift index 8332a78..4744465 100644 --- a/Cryptohopper-iOS-SDK/APIContext/CryptohopperUser.swift +++ b/Cryptohopper-iOS-SDK/APIContext/CryptohopperUser.swift @@ -434,7 +434,21 @@ import UIKit completion(.failure(err)) } } - + + /// Ask AI with streaming support. onEvent fires per SSE event (main + /// queue). completion fires once: AskAIAnswer for a plain-JSON reply, + /// nil after a successful stream, or an error. + public static func performAskAIStreaming(page: String, + question: String, + onEvent: @escaping (AskAIStreamEvent) -> Void, + completion: @escaping (Result) -> Void) { + let request = HopperAPIAskAIStreamingRequest(page: page, + question: question, + onEvent: onEvent, + completion: completion) + request.start() + } + /*! * @discussion Vote Ask AI (Feedback) * diff --git a/Cryptohopper-iOS-SDK/SharedModels/V1Models/User/Models/AskAIAnswer.swift b/Cryptohopper-iOS-SDK/SharedModels/V1Models/User/Models/AskAIAnswer.swift index 95dc129..93c7390 100644 --- a/Cryptohopper-iOS-SDK/SharedModels/V1Models/User/Models/AskAIAnswer.swift +++ b/Cryptohopper-iOS-SDK/SharedModels/V1Models/User/Models/AskAIAnswer.swift @@ -20,6 +20,12 @@ public class AskAIAnswer: Codable { case question = "question" case voteId = "vote_id" } - + + /// Suggestion answers arrive from the API as a bare string in `data` + /// instead of an object; this lets the response wrap them. + internal init(content: String?) { + self.content = content + } + } diff --git a/Cryptohopper-iOS-SDKTests/APISpec/AI/AskAIStreamParserSpec.swift b/Cryptohopper-iOS-SDKTests/APISpec/AI/AskAIStreamParserSpec.swift new file mode 100644 index 0000000..4793ca2 --- /dev/null +++ b/Cryptohopper-iOS-SDKTests/APISpec/AI/AskAIStreamParserSpec.swift @@ -0,0 +1,105 @@ +import Foundation +import XCTest +import Quick +import Nimble +@testable import Cryptohopper_iOS_SDK + +class AskAIStreamParserSpec: QuickSpec { + + override func spec() { + + var parser: AskAIStreamParser! + + beforeEach { + parser = AskAIStreamParser() + } + + func feed(_ text: String) -> [AskAIStreamEvent] { + return parser.feed(text.data(using: .utf8)!) + } + + context("single complete frames") { + it("parses a delta frame") { + let events = feed("event: delta\ndata: {\"content\": \"Hello\"}\n\n") + expect(events.count).to(equal(1)) + guard case .delta(let text)? = events.first else { return fail("expected delta") } + expect(text).to(equal("Hello")) + } + it("parses a done frame with run and session ids") { + let events = feed("event: done\ndata: {\"run_id\": \"r1\", \"session_id\": \"s1\"}\n\n") + guard case .done(let runId, let sessionId)? = events.first else { return fail("expected done") } + expect(runId).to(equal("r1")) + expect(sessionId).to(equal("s1")) + } + it("maps a done frame carrying an error field to an error event") { + let events = feed("event: done\ndata: {\"error\": \"boom\"}\n\n") + guard case .error(let message)? = events.first else { return fail("expected error") } + expect(message).to(equal("boom")) + } + it("parses final as done") { + let events = feed("event: final\ndata: {\"run_id\": \"r2\"}\n\n") + guard case .done(let runId, _)? = events.first else { return fail("expected done") } + expect(runId).to(equal("r2")) + } + it("parses an error frame") { + let events = feed("event: error\ndata: {\"message\": \"nope\"}\n\n") + guard case .error(let message)? = events.first else { return fail("expected error") } + expect(message).to(equal("nope")) + } + it("uses the payload type field when the event line is missing") { + let events = feed("data: {\"type\": \"delta\", \"content\": \"x\"}\n\n") + guard case .delta(let text)? = events.first else { return fail("expected delta") } + expect(text).to(equal("x")) + } + } + + context("frames to ignore") { + it("ignores conversation, tool and metadata events without failing") { + let raw = "event: conversation\ndata: {\"conversation_id\": \"c1\"}\n\n" + + "event: tool_call\ndata: {\"tool_call_id\": \"t\", \"tool_name\": \"n\"}\n\n" + + "event: metadata\ndata: {\"outcome\": \"answered\"}\n\n" + + "event: delta\ndata: {\"content\": \"kept\"}\n\n" + let events = feed(raw) + expect(events.count).to(equal(1)) + } + it("ignores comment/ping frames") { + let events = feed(": ping\n\nevent: delta\ndata: {\"content\": \"a\"}\n\n") + expect(events.count).to(equal(1)) + } + it("ignores malformed json frames") { + let events = feed("event: delta\ndata: {broken\n\nevent: delta\ndata: {\"content\": \"ok\"}\n\n") + expect(events.count).to(equal(1)) + } + } + + context("chunk boundaries") { + it("assembles a frame split across chunks, including mid-character splits") { + let full = "event: delta\ndata: {\"content\": \"héllo\"}\n\n".data(using: .utf8)! + // split inside the two-byte "é" + let splitIndex = full.range(of: "h".data(using: .utf8)!)!.upperBound + 1 + var events = parser.feed(full.subdata(in: 0..