feat: add authenticated durable native Hope chat

This commit is contained in:
snowspeeder committed 2026-10-03 09:06:06 -04:00
1 parent 74d2b63614
commit 7dcd06c911
15 files changed
+1675 -9

No files matched your search

+151
View File
@@ -0,0 +1,151 @@
import XCTest
@testable import Voice
@MainActor
final class ChatTests: XCTestCase {
func testOfflineCRUDSearchPinsArchiveMoveAndPreferences() async throws {
let api = OfflineChatAPI()
let store = ChatStore(api: api)
await store.load()
XCTAssertTrue(store.conversations.isEmpty)
await store.saveWorkspace(nil, name: "Writing", instructions: "Be concise")
let space = try XCTUnwrap(store.workspaces.first)
await store.create(workspace: space.id)
let original = try XCTUnwrap(store.selected)
store.draft = "A durable offline message"
await store.send()
XCTAssertEqual(store.messages.count, 2)
XCTAssertEqual(store.draft, "")
await store.patch(original, changes: ["title": "Weekend", "pinned": true])
XCTAssertEqual(store.selected?.title, "Weekend")
XCTAssertTrue(store.selected?.pinned == true)
await store.load(query: "durable")
XCTAssertEqual(store.conversations.count, 1)
await store.savePreferences(palette: "ocean", appearance: "dark")
XCTAssertEqual(store.preferences.palette, "ocean")
await store.deleteWorkspace(space)
XCTAssertNil(store.conversations.first?.workspaceId)
let renamed = try XCTUnwrap(store.selected)
await store.patch(renamed, changes: ["archived": true])
await store.load()
XCTAssertTrue(store.conversations.isEmpty)
await store.load(archived: true)
XCTAssertEqual(store.conversations.count, 1)
await store.delete(try XCTUnwrap(store.conversations.first))
XCTAssertNil(store.selected)
XCTAssertTrue(store.messages.isEmpty)
}
func testOfflineResearchTruthfulExportAndTeardown() async throws {
let store = ChatStore(api: OfflineChatAPI())
await store.create()
store.draft = "Export this"; await store.send()
await store.loadResearch()
XCTAssertFalse(store.researchAvailable)
await store.exportConversation(format: "markdown")
XCTAssertTrue(store.exportText?.contains("Export this") == true)
store.foreground(true)
store.foreground(false)
store.close()
XCTAssertNil(store.exportText)
XCTAssertFalse(store.research)
}
func testBranchAndRegeneratePreserveOriginal() async throws {
let store = ChatStore(api: OfflineChatAPI())
await store.create()
let original = try XCTUnwrap(store.selected)
store.draft = "Original question"; await store.send()
let user = try XCTUnwrap(store.messages.first)
await store.branch(user, edited: "Edited question")
XCTAssertNotEqual(store.selected?.id, original.id)
XCTAssertEqual(store.messages.first?.content, "Edited question")
await store.open(original)
XCTAssertEqual(store.messages.first?.content, "Original question")
let answer = try XCTUnwrap(store.messages.last)
await store.branch(answer, regenerate: true)
XCTAssertNotEqual(store.selected?.id, original.id)
XCTAssertEqual(store.messages.first?.content, "Original question")
}
func testReplayFullReplacementAndDuplicateCursor() async throws {
let store = ChatStore(api: OfflineChatAPI())
await store.create()
let id = try XCTUnwrap(store.selected?.id)
func event(_ seq: Int, _ text: String) -> ChatEvent {
ChatEvent(id: seq, name: "message", data: Data("{\"message\":{\"id\":\"m\",\"conversation_id\":\"\(id)\",\"role\":\"assistant\",\"content\":\"\(text)\",\"status\":\"streaming\",\"run_id\":\"r\",\"created_at\":1}}".utf8))
}
try store.apply(event(1, "A"), run: "r")
try store.apply(event(2, "Answer"), run: "r")
try store.apply(event(1, "Duplicate"), run: "r")
XCTAssertEqual(store.messages.count, 1)
XCTAssertEqual(store.messages.first?.content, "Answer")
store.close()
XCTAssertTrue(store.messages.isEmpty)
XCTAssertNil(store.selected)
XCTAssertEqual(store.draft, "")
}
func testSSECommentsMultilineAndFraming() throws {
var parser = SSEParser()
XCTAssertNil(parser.consume(": keepalive"))
XCTAssertNil(parser.consume("id: 12"))
XCTAssertNil(parser.consume("event: message"))
XCTAssertNil(parser.consume("data: {"))
XCTAssertNil(parser.consume("data: }"))
let result = try XCTUnwrap(parser.consume(""))
XCTAssertEqual(result.id, 12)
XCTAssertEqual(result.name, "message")
XCTAssertEqual(String(data: result.data, encoding: .utf8), "{\n}")
XCTAssertNil(parser.consume(""))
}
func testRevisionConflictRetainsDraft() async throws {
let store = ChatStore(api: OfflineChatAPI())
await store.create(); let old = try XCTUnwrap(store.selected)
await store.patch(old, changes: ["title": "First"])
store.draft = "Keep me"
await store.patch(old, changes: ["title": "Stale"])
XCTAssertEqual(store.selected?.title, "First")
XCTAssertEqual(store.draft, "Keep me")
XCTAssertNotNil(store.error)
}
func testAdvancedOfflineUploadArtifactTaskMemoryAndPrivacy() async throws {
let api = OfflineChatAPI()
let features = ChatFeatureStore(api: api)
await features.load()
XCTAssertTrue(features.enabled.contains("attachments"))
let uploaded = await features.upload(Data("Private notes".utf8), filename: "notes.txt", mime: "text/plain")
let upload = try XCTUnwrap(uploaded)
await features.preview(upload)
XCTAssertEqual(features.previewText, "Private notes")
await features.saveArtifact(nil, title: "Draft", kind: "markdown", content: "# Draft")
let item = try XCTUnwrap(features.artifacts.first)
await features.saveArtifact(item, title: "Revised", kind: "markdown", content: "# Revised")
XCTAssertEqual(features.artifacts.first?.revision, 2)
await features.saveTask(nil, name: "Reminder", schedule: "in 1 hour", prompt: "Check notes")
let task = try XCTUnwrap(features.tasks.first)
await features.taskAction(task, action: "pause")
XCTAssertEqual(features.tasks.first?.enabled, false)
let memory = try XCTUnwrap(features.memory.first)
await features.saveMemory(memory, content: "New memory")
XCTAssertEqual(features.memory.first?.content, "New memory")
await features.saveMemory(memory, content: "Stale overwrite")
XCTAssertNotNil(features.error)
XCTAssertEqual(features.memory.first?.content, "New memory")
features.close()
XCTAssertTrue(features.attachments.isEmpty)
XCTAssertTrue(features.artifacts.isEmpty)
XCTAssertTrue(features.tasks.isEmpty)
XCTAssertTrue(features.memory.isEmpty)
XCTAssertNil(features.previewText)
}
func testSharedThemeContrastAcrossPalettes() {
func luminance(_ rgb: HopeRGB) -> Double {
func channel(_ x: Double) -> Double { x <= 0.04045 ? x / 12.92 : pow((x + 0.055) / 1.055, 2.4) }
return 0.2126 * channel(rgb.red) + 0.7152 * channel(rgb.green) + 0.0722 * channel(rgb.blue)
}
for palette in HopePalette.allCases { for dark in [false, true] {
let ground = luminance(HopeTheme.rgb(.ground, palette: palette, dark: dark))
for role in [HopeThemeRole.ink, .quiet, .accent] {
let text = luminance(HopeTheme.rgb(role, palette: palette, dark: dark))
XCTAssertGreaterThanOrEqual((max(text, ground) + 0.05) / (min(text, ground) + 0.05), 4.5, "\(palette) \(role) \(dark)")
}
} }
}
}
+52
View File
@@ -0,0 +1,52 @@
import XCTest
@testable import Voice
private final class ChatURLProtocol: 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, body) = try Self.handler!(request)
client?.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil)!, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: body); client?.urlProtocolDidFinishLoading(self)
} catch { client?.urlProtocol(self, didFailWithError: error) }
}
override func stopLoading() {}
}
final class ChatTransportTests: XCTestCase {
func testBearerSameOriginQueryAndRawUpload() async throws {
guard case .success(let config) = ServerConfig.parse("https://hope.example:9178") else { XCTFail(); return }
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [ChatURLProtocol.self]
let api = ChatAPI(config: config, token: "offline-test-bearer", session: URLSession(configuration: configuration))
ChatURLProtocol.handler = { request in
XCTAssertEqual(request.url?.host, "hope.example")
XCTAssertEqual(request.url?.scheme, "https")
XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer offline-test-bearer")
XCTAssertEqual(URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?.queryItems?.first?.value, "a & b")
return (200, Data("{\"conversations\":[],\"next_cursor\":null}".utf8))
}
_ = try await api.request("api/conversations", query: [URLQueryItem(name: "q", value: "a & b")])
ChatURLProtocol.handler = { request in
XCTAssertEqual(request.httpMethod, "POST")
XCTAssertEqual(request.url?.path, "/api/attachments")
XCTAssertEqual(request.value(forHTTPHeaderField: "X-Filename"), "notes.txt")
XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "text/plain")
return (201, Data("{}".utf8))
}
_ = try await api.upload(data: Data("test".utf8), filename: "notes.txt", mime: "text/plain")
api.cancel(); ChatURLProtocol.handler = nil
}
func testUnauthorizedAndRedirectStatusCannotBecomeSuccess() async throws {
guard case .success(let config) = ServerConfig.parse("https://hope.example") else { XCTFail(); return }
let configuration = URLSessionConfiguration.ephemeral; configuration.protocolClasses = [ChatURLProtocol.self]
let api = ChatAPI(config: config, token: "offline", session: URLSession(configuration: configuration))
for status in [401, 403, 302, 409] {
ChatURLProtocol.handler = { _ in (status, Data()) }
do { _ = try await api.request("api/conversations"); XCTFail("Expected rejection") }
catch ChatFailure.status(let actual) { XCTAssertEqual(actual, status) }
}
api.cancel(); ChatURLProtocol.handler = nil
}
}