feat(ios): align chat library memory and tasks with web
This commit is contained in:
1 parent
8905dc2c2a
commit
235dd65487
12 files changed
+957
-128
No files matched your search
@@ -0,0 +1,156 @@
|
||||
import XCTest
|
||||
@testable import Voice
|
||||
|
||||
private final class FeatureParityURLProtocol: URLProtocol {
|
||||
static var handler: ((URLRequest) throws -> (Int, Data))?
|
||||
override class func canInit(with request: URLRequest) -> Bool { true }
|
||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||||
override func startLoading() {
|
||||
do {
|
||||
let (status, data) = try Self.handler!(request)
|
||||
client?.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil)!, cacheStoragePolicy: .notAllowed)
|
||||
client?.urlProtocol(self, didLoad: data)
|
||||
client?.urlProtocolDidFinishLoading(self)
|
||||
} catch { client?.urlProtocol(self, didFailWithError: error) }
|
||||
}
|
||||
override func stopLoading() {}
|
||||
}
|
||||
|
||||
private final class SuspendedFeatureTransport: ChatTransport {
|
||||
var handler: (@MainActor (String) async throws -> Data)?
|
||||
func request(_ path: String, method: String, body: [String: Any]?, query: [URLQueryItem]) async throws -> Data { try await handler!(path) }
|
||||
func events(run: String, after: Int, receive: @escaping @MainActor (ChatEvent) throws -> Void) async throws {}
|
||||
func cancel() {}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ChatFeatureParityTests: XCTestCase {
|
||||
private func makeStore() throws -> (ChatFeatureStore, ChatAPI) {
|
||||
guard case .success(let config) = ServerConfig.parse("https://hope.example") else { throw ChatFailure.invalidResponse }
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.protocolClasses = [FeatureParityURLProtocol.self]
|
||||
let api = ChatAPI(config: config, token: "offline-fixture", session: URLSession(configuration: configuration))
|
||||
return (ChatFeatureStore(api: api), api)
|
||||
}
|
||||
private func attachment(status: String = "ready", mime: String = "text/plain") -> ChatAttachment {
|
||||
ChatAttachment(id: String(repeating: "a", count: 32), filename: "notes.txt", mime: mime, size: 5, extractionStatus: status, createdAt: 1)
|
||||
}
|
||||
private func envelope(_ item: ChatAttachment) throws -> Data {
|
||||
try JSONSerialization.data(withJSONObject: ["attachment": ["id": item.id, "filename": item.filename, "mime": item.mime, "size": item.size, "extraction_status": item.extractionStatus, "created_at": item.createdAt]])
|
||||
}
|
||||
func testMetadataRevalidationRejectsUnavailableAndDeletedFiles() async throws {
|
||||
let (store, api) = try makeStore(); defer { api.cancel(); FeatureParityURLProtocol.handler = nil }
|
||||
let old = attachment()
|
||||
let unavailable = try envelope(attachment(status: "unavailable"))
|
||||
var metadataStatus = 200
|
||||
FeatureParityURLProtocol.handler = { request in
|
||||
if request.url?.path == "/api/capabilities" { return (200, Data("{\"features\":{\"attachments\":true}}".utf8)) }
|
||||
XCTAssertEqual(request.url?.path, "/api/attachments/\(old.id)")
|
||||
return (metadataStatus, unavailable)
|
||||
}
|
||||
let blocked = await store.validatedAttachment(old)
|
||||
XCTAssertNil(blocked)
|
||||
XCTAssertTrue(store.error?.contains("unavailable") == true)
|
||||
metadataStatus = 404
|
||||
let deleted = await store.validatedAttachment(old)
|
||||
XCTAssertNil(deleted)
|
||||
XCTAssertTrue(store.error?.contains("no longer available") == true)
|
||||
}
|
||||
func testCapabilityRefreshFailsClosedAndImagesCannotUpload() async throws {
|
||||
let (store, api) = try makeStore(); defer { api.cancel(); FeatureParityURLProtocol.handler = nil }
|
||||
store.enabled = ["attachments", "image_input"]
|
||||
FeatureParityURLProtocol.handler = { _ in (503, Data()) }
|
||||
let refreshed = await store.refreshCapabilities()
|
||||
XCTAssertFalse(refreshed)
|
||||
XCTAssertTrue(store.enabled.isEmpty)
|
||||
FeatureParityURLProtocol.handler = { request in
|
||||
XCTAssertEqual(request.url?.path, "/api/capabilities", "Unqualified images must never reach upload")
|
||||
return (200, Data("{\"features\":{\"attachments\":true,\"image_input\":false}}".utf8))
|
||||
}
|
||||
let uploaded = await store.upload(Data([1, 2, 3]), filename: "photo.jpg", mime: "image/jpeg")
|
||||
XCTAssertNil(uploaded)
|
||||
XCTAssertTrue(store.error?.contains("Image input") == true)
|
||||
}
|
||||
func testLateCapabilityResponseCannotRestoreOldImageQualification() async throws {
|
||||
let api = SuspendedFeatureTransport()
|
||||
let store = ChatFeatureStore(api: api)
|
||||
let firstStarted = expectation(description: "First capability request suspended")
|
||||
var firstResponse: CheckedContinuation<Data, Error>?
|
||||
var requests = 0
|
||||
api.handler = { _ in
|
||||
requests += 1
|
||||
if requests == 1 {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
firstResponse = continuation
|
||||
firstStarted.fulfill()
|
||||
}
|
||||
}
|
||||
return Data("{\"features\":{\"attachments\":true,\"image_input\":false}}".utf8)
|
||||
}
|
||||
let first = Task { await store.refreshCapabilities() }
|
||||
await fulfillment(of: [firstStarted], timeout: 2)
|
||||
let latest = await store.refreshCapabilities()
|
||||
XCTAssertTrue(latest)
|
||||
firstResponse?.resume(returning: Data("{\"features\":{\"attachments\":true,\"image_input\":true}}".utf8))
|
||||
let stale = await first.value
|
||||
XCTAssertFalse(stale)
|
||||
XCTAssertFalse(store.enabled.contains("image_input"))
|
||||
}
|
||||
func testBlockedStatusesIncludeUnknownAndImageQualification() {
|
||||
for status in ["failed", "encrypted", "unavailable", "pending"] {
|
||||
XCTAssertNotNil(ChatFeatureStore.blockedReason(attachment(status: status), imageInput: true))
|
||||
}
|
||||
XCTAssertNil(ChatFeatureStore.blockedReason(attachment(status: "truncated"), imageInput: false))
|
||||
XCTAssertNotNil(ChatFeatureStore.blockedReason(attachment(status: "image", mime: "image/png"), imageInput: false))
|
||||
}
|
||||
func testPausedTaskAndOptionalRepeatUseContractFields() async throws {
|
||||
let (store, api) = try makeStore(); defer { api.cancel(); FeatureParityURLProtocol.handler = nil }
|
||||
FeatureParityURLProtocol.handler = { request in
|
||||
XCTAssertEqual(request.url?.path, "/api/tasks")
|
||||
XCTAssertEqual(request.httpMethod, "POST")
|
||||
var bytes = request.httpBody ?? Data()
|
||||
if bytes.isEmpty, let stream = request.httpBodyStream {
|
||||
stream.open(); defer { stream.close() }
|
||||
var buffer = [UInt8](repeating: 0, count: 1024)
|
||||
while stream.hasBytesAvailable { let count = stream.read(&buffer, maxLength: buffer.count); if count <= 0 { break }; bytes.append(contentsOf: buffer.prefix(count)) }
|
||||
}
|
||||
let body = try XCTUnwrap(JSONSerialization.jsonObject(with: bytes) as? [String: Any])
|
||||
XCTAssertEqual(body["paused"] as? Bool, true)
|
||||
XCTAssertEqual(body["repeat"] as? Int, 3)
|
||||
XCTAssertEqual(body["schedule"] as? String, "0 9 * * *")
|
||||
return (201, Data("{\"task\":{\"id\":\"aaaaaaaaaaaa\",\"enabled\":false,\"repeat\":3}}".utf8))
|
||||
}
|
||||
await store.saveTask(nil, name: "Daily", schedule: "0 9 * * *", prompt: "Review", repeatCount: 3, paused: true)
|
||||
XCTAssertNil(store.error)
|
||||
XCTAssertEqual(store.tasks.first?.enabled, false)
|
||||
XCTAssertEqual(store.tasks.first?.repeat, 3)
|
||||
}
|
||||
func testMemoryConflictRequiresExplicitLatestRevisionAndKeepsDraft() async throws {
|
||||
let api = OfflineChatAPI()
|
||||
let store = ChatFeatureStore(api: api)
|
||||
await store.load()
|
||||
let original = try XCTUnwrap(store.memory.first)
|
||||
let first = await store.saveMemory(original, content: "Server update")
|
||||
XCTAssertTrue(first)
|
||||
let localDraft = "Keep my draft"
|
||||
let stale = await store.saveMemory(original, content: localDraft)
|
||||
XCTAssertFalse(stale)
|
||||
XCTAssertTrue(store.error?.contains("Compare") == true)
|
||||
let fetched = await store.latestMemory(original)
|
||||
let latest = try XCTUnwrap(fetched)
|
||||
XCTAssertEqual(latest.content, "Server update")
|
||||
XCTAssertEqual(localDraft, "Keep my draft")
|
||||
let accepted = await store.saveMemory(latest, content: localDraft)
|
||||
XCTAssertTrue(accepted)
|
||||
XCTAssertEqual(store.memory.first?.content, localDraft)
|
||||
}
|
||||
func testClosedStoreCannotReloadOrPreviewAccountData() async throws {
|
||||
let (store, api) = try makeStore(); defer { api.cancel(); FeatureParityURLProtocol.handler = nil }
|
||||
FeatureParityURLProtocol.handler = { _ in XCTFail("Closed account must not request private data"); return (200, Data()) }
|
||||
store.close()
|
||||
await store.load()
|
||||
await store.preview(attachment())
|
||||
XCTAssertTrue(store.enabled.isEmpty)
|
||||
XCTAssertNil(store.previewAttachment)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import XCTest
|
||||
@testable import Voice
|
||||
|
||||
private final class ParityChatAPI: ChatTransport {
|
||||
struct Request { let path: String; let method: String; let body: [String: Any]?; let query: [URLQueryItem] }
|
||||
var requests: [Request] = []
|
||||
var handler: ((Request) async throws -> Data?)?
|
||||
private let fallback = OfflineChatAPI()
|
||||
func request(_ path: String, method: String, body: [String: Any]?, query: [URLQueryItem]) async throws -> Data {
|
||||
let request = Request(path: path, method: method, body: body, query: query)
|
||||
requests.append(request)
|
||||
if let data = try await handler?(request) { return data }
|
||||
return try await fallback.request(path, method: method, body: body, query: query)
|
||||
}
|
||||
func events(run: String, after: Int, receive: @escaping @MainActor (ChatEvent) throws -> Void) async throws {}
|
||||
func cancel() {}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ChatParityTests: XCTestCase {
|
||||
private func conversation(_ id: String, workspace: String? = nil, revision: Int = 1) -> HopeConversation {
|
||||
HopeConversation(id: id, title: id, workspaceId: workspace, pinned: false, pinRank: 0, archived: false, temporary: false, revision: revision, createdAt: 1, updatedAt: 1)
|
||||
}
|
||||
private func encoded(_ value: [String: Any]) throws -> Data { try JSONSerialization.data(withJSONObject: value) }
|
||||
private func projection(_ conversation: HopeConversation) throws -> Any {
|
||||
let encoder = JSONEncoder(); encoder.keyEncodingStrategy = .convertToSnakeCase
|
||||
return try JSONSerialization.jsonObject(with: encoder.encode(conversation))
|
||||
}
|
||||
private func attachment(_ id: String, mime: String = "text/plain", status: String = "ready") -> ChatAttachment {
|
||||
ChatAttachment(id: id, filename: "notes.txt", mime: mime, size: 12, extractionStatus: status, createdAt: 1)
|
||||
}
|
||||
func testWorkspaceFilterPrecedesPagingAndScopeChangeDropsCursor() async throws {
|
||||
let api = ParityChatAPI(); let store = ChatStore(api: api)
|
||||
api.handler = { request in
|
||||
guard request.path == "api/conversations" else { return nil }
|
||||
return Data("{\"conversations\":[],\"next_cursor\":\"page-two\"}".utf8)
|
||||
}
|
||||
await store.load(query: "search", archived: true, workspace: "writing")
|
||||
await store.load(query: "search", archived: true, workspace: "writing", more: true)
|
||||
await store.load(query: "search", archived: true, workspace: "different", more: true)
|
||||
let lists = api.requests.filter { $0.path == "api/conversations" }
|
||||
XCTAssertEqual(lists.count, 3)
|
||||
XCTAssertEqual(lists[0].query.first { $0.name == "workspace_id" }?.value, "writing")
|
||||
XCTAssertEqual(lists[1].query.first { $0.name == "cursor" }?.value, "page-two")
|
||||
XCTAssertNil(lists[2].query.first { $0.name == "cursor" })
|
||||
}
|
||||
func testSyncPreservesTwoLoadedPagesAndContinuationCursor() async throws {
|
||||
let api = ParityChatAPI(); let store = ChatStore(api: api)
|
||||
let first = conversation("first", workspace: "writing")
|
||||
let older = conversation("older", workspace: "writing")
|
||||
var changed = older; changed.title = "Renamed remotely"; changed.revision = 2
|
||||
api.handler = { [self] request in
|
||||
if request.path == "api/conversations" {
|
||||
let secondPage = request.query.contains { $0.name == "cursor" && $0.value == "page-two" }
|
||||
return try encoded(["conversations": [projection(secondPage ? older : first)], "next_cursor": secondPage ? "page-three" : "page-two"])
|
||||
}
|
||||
if request.path == "api/sync" {
|
||||
return try encoded(["changes": [["id": changed.id, "kind": "conversation", "deleted": false, "data": projection(changed)]], "cursor": 1])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
await store.load(query: "message match", workspace: "writing")
|
||||
await store.load(query: "message match", workspace: "writing", more: true)
|
||||
XCTAssertEqual(store.conversations.map(\.id), [first.id, older.id])
|
||||
XCTAssertEqual(store.nextCursor, "page-three")
|
||||
await store.synchronize()
|
||||
XCTAssertEqual(store.conversations.map(\.id), [first.id, older.id])
|
||||
XCTAssertEqual(store.conversations.last?.title, "Renamed remotely")
|
||||
XCTAssertEqual(store.nextCursor, "page-three")
|
||||
XCTAssertEqual(api.requests.filter { $0.path == "api/conversations" }.count, 2)
|
||||
}
|
||||
func testOtherRowMutationPreservesComposerAndActiveRunAndScope() async throws {
|
||||
let api = ParityChatAPI(); let store = ChatStore(api: api)
|
||||
let selected = conversation("selected", workspace: "writing")
|
||||
let other = conversation("other", workspace: "writing")
|
||||
let moved = conversation("other", workspace: "elsewhere", revision: 2)
|
||||
api.handler = { [self] request in
|
||||
if request.method == "PATCH" { return try encoded(["conversation": projection(moved)]) }
|
||||
if request.path == "api/conversations" { return Data("{\"conversations\":[],\"next_cursor\":null}".utf8) }
|
||||
return nil
|
||||
}
|
||||
await store.load(workspace: "writing")
|
||||
store.selected = selected; store.conversations = [selected, other]; store.draft = "Keep draft"
|
||||
store.messages = [HopeMessage(id: "m", conversationId: selected.id, role: "assistant", content: "Streaming", status: "streaming", runId: "r", createdAt: 1)]
|
||||
store.runs = [HopeRun(id: "r", conversationId: selected.id, status: "running", messageId: "m", createdAt: 1, updatedAt: 1, error: nil)]
|
||||
XCTAssertFalse(store.canMutate(selected)); XCTAssertTrue(store.canMutate(other))
|
||||
await store.patch(other, changes: ["workspace_id": "elsewhere"])
|
||||
XCTAssertEqual(store.selected, selected); XCTAssertEqual(store.draft, "Keep draft")
|
||||
XCTAssertEqual(store.messages.first?.content, "Streaming"); XCTAssertEqual(store.activeRun?.id, "r")
|
||||
XCTAssertEqual(api.requests.last { $0.path == "api/conversations" }?.query.first { $0.name == "workspace_id" }?.value, "writing")
|
||||
XCTAssertFalse(store.conversations.contains { $0.id == other.id })
|
||||
}
|
||||
func testMutationCompletionSurvivesNavigationButNotAccountClose() async throws {
|
||||
for close in [false, true] {
|
||||
let api = ParityChatAPI(); let store = ChatStore(api: api)
|
||||
await store.create(); let first = try XCTUnwrap(store.selected)
|
||||
await store.create(); let second = try XCTUnwrap(store.selected)
|
||||
var finish: CheckedContinuation<Void, Never>?
|
||||
var started: CheckedContinuation<Void, Never>?
|
||||
api.handler = { request in
|
||||
if request.method == "PATCH" {
|
||||
started?.resume(); started = nil
|
||||
await withCheckedContinuation { finish = $0 }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
let operation = Task { await store.patch(first, changes: ["title": "Renamed"]) }
|
||||
await withCheckedContinuation { started = $0 }
|
||||
if close { store.close() } else { await store.open(first); await store.open(second) }
|
||||
finish?.resume(); await operation.value
|
||||
if close { XCTAssertTrue(store.conversations.isEmpty); XCTAssertNil(store.selected) }
|
||||
else { XCTAssertEqual(store.selected?.id, second.id); XCTAssertEqual(store.conversations.first { $0.id == first.id }?.title, "Renamed") }
|
||||
}
|
||||
}
|
||||
func testStaleRevisionRefreshesMetadataOnlyAndRetryIsActionable() async throws {
|
||||
let store = ChatStore(api: OfflineChatAPI())
|
||||
await store.create(); let stale = try XCTUnwrap(store.selected)
|
||||
await store.patch(stale, changes: ["title": "Latest"])
|
||||
store.draft = "Private unsent draft"
|
||||
await store.patch(stale, changes: ["title": "Stale overwrite"])
|
||||
XCTAssertEqual(store.selected?.title, "Latest"); XCTAssertEqual(store.draft, "Private unsent draft")
|
||||
XCTAssertTrue(store.error?.contains("latest details are loaded") == true)
|
||||
await store.patch(try XCTUnwrap(store.selected), changes: ["title": "Retry"])
|
||||
XCTAssertEqual(store.selected?.title, "Retry")
|
||||
}
|
||||
func testAttachmentQueueBindsComposerAndRejectsPendingAndUnsupportedFiles() async throws {
|
||||
let store = ChatStore(api: OfflineChatAPI()); await store.create()
|
||||
store.features.enabled = ["attachments"]
|
||||
let epoch = store.draftGeneration
|
||||
let accepted = await store.queueAttachment(attachment("a"), expectedDraftGeneration: epoch)
|
||||
XCTAssertTrue(accepted)
|
||||
let image = await store.queueAttachment(attachment("image", mime: "image/png", status: "image"), expectedDraftGeneration: epoch)
|
||||
XCTAssertFalse(image)
|
||||
let failed = await store.queueAttachment(attachment("failed", status: "encrypted"), expectedDraftGeneration: epoch)
|
||||
XCTAssertFalse(failed)
|
||||
for i in 1...7 { _ = await store.queueAttachment(attachment("extra-\(i)"), expectedDraftGeneration: epoch) }
|
||||
let excess = await store.queueAttachment(attachment("ninth"), expectedDraftGeneration: epoch)
|
||||
XCTAssertFalse(excess); XCTAssertEqual(store.selectedAttachmentIds.count, 8)
|
||||
store.busy = true
|
||||
let pending = await store.queueAttachment(attachment("pending"), expectedDraftGeneration: epoch)
|
||||
XCTAssertFalse(pending); store.busy = false
|
||||
await store.create()
|
||||
let stale = await store.queueAttachment(attachment("stale"), expectedDraftGeneration: epoch)
|
||||
XCTAssertFalse(stale); XCTAssertTrue(store.selectedAttachmentIds.isEmpty)
|
||||
store.close()
|
||||
let closed = await store.queueAttachment(attachment("closed"))
|
||||
XCTAssertFalse(closed)
|
||||
}
|
||||
func testUnavailableAttachmentCanBeRemovedAndFreshQueueWins() async throws {
|
||||
let store = ChatStore(api: OfflineChatAPI()); await store.create()
|
||||
store.features.enabled = ["attachments"]
|
||||
let current = attachment("a")
|
||||
let accepted = await store.queueAttachment(current)
|
||||
XCTAssertTrue(accepted)
|
||||
store.features.attachments = [attachment("a", status: "failed")]
|
||||
XCTAssertEqual(store.queuedAttachment("a")?.extractionStatus, "ready")
|
||||
XCTAssertTrue(store.canSendAttachments)
|
||||
store.selectedAttachmentIds.insert("missing")
|
||||
XCTAssertNil(store.queuedAttachment("missing"))
|
||||
XCTAssertFalse(store.canSendAttachments)
|
||||
store.selectedAttachmentIds.remove("missing")
|
||||
XCTAssertTrue(store.canSendAttachments)
|
||||
store.close()
|
||||
}
|
||||
func testEditAndRegenerateResendExactOriginalAttachmentIDs() async throws {
|
||||
for regenerate in [false, true] {
|
||||
let api = ParityChatAPI(); let store = ChatStore(api: api)
|
||||
await store.create(); store.draft = "Original"; await store.send()
|
||||
let ids = ["00000000000000000000000000000001", "00000000000000000000000000000002"]
|
||||
store.messages[0].attachments = ids
|
||||
api.handler = { request in
|
||||
if request.path.hasPrefix("api/attachments/") {
|
||||
let id = String(request.path.split(separator: "/").last!)
|
||||
return Data("{\"attachment\":{\"id\":\"\(id)\",\"filename\":\"notes.txt\",\"mime\":\"text/plain\",\"size\":12,\"extraction_status\":\"ready\",\"created_at\":1}}".utf8)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
let target = try XCTUnwrap(regenerate ? store.messages.last : store.messages.first)
|
||||
await store.branch(target, edited: regenerate ? nil : "Edited", regenerate: regenerate)
|
||||
let resent = try XCTUnwrap(api.requests.last { $0.path.hasSuffix("/messages") })
|
||||
XCTAssertEqual(resent.body?["attachment_ids"] as? [String], ids)
|
||||
}
|
||||
}
|
||||
func testModelSaveRefreshFailureDisablesImageCapability() async throws {
|
||||
let api = ParityChatAPI(); let store = ChatStore(api: api)
|
||||
await store.create()
|
||||
store.features.enabled = ["attachments", "image_input"]
|
||||
let image = attachment("qualified-image", mime: "image/png", status: "image")
|
||||
let queued = await store.queueAttachment(image, expectedDraftGeneration: store.draftGeneration)
|
||||
XCTAssertTrue(queued); XCTAssertTrue(store.canSendAttachments)
|
||||
store.draft = "Keep this image draft"
|
||||
api.handler = { request in
|
||||
if request.path == "api/settings/model" { return Data("{\"settings\":{\"model\":\"auto\",\"thinking_level\":\"off\"},\"message\":null}".utf8) }
|
||||
if request.path == "api/capabilities" { throw ChatFailure.status(503) }
|
||||
return nil
|
||||
}
|
||||
await store.saveModel()
|
||||
XCTAssertFalse(store.features.enabled.contains("image_input"))
|
||||
XCTAssertTrue(api.requests.contains { $0.path == "api/capabilities" })
|
||||
XCTAssertFalse(store.canSendAttachments)
|
||||
await store.send()
|
||||
XCTAssertFalse(api.requests.contains { $0.path.hasSuffix("/messages") })
|
||||
XCTAssertEqual(store.draft, "Keep this image draft")
|
||||
XCTAssertEqual(store.selectedAttachmentIds, Set([image.id]))
|
||||
XCTAssertTrue(store.error?.contains("current model") == true)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user