ios: add multi-polling support
This commit is contained in:
@@ -116,6 +116,10 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
try await request("/v1/models", method: "GET", responseType: ModelCatalogResponse.self)
|
try await request("/v1/models", method: "GET", responseType: ModelCatalogResponse.self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getActiveRuns() async throws -> ActiveRunsResponse {
|
||||||
|
try await request("/v1/active-runs", method: "GET", responseType: ActiveRunsResponse.self)
|
||||||
|
}
|
||||||
|
|
||||||
func runCompletionStream(
|
func runCompletionStream(
|
||||||
body: CompletionStreamRequest,
|
body: CompletionStreamRequest,
|
||||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
@@ -133,43 +137,35 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
)
|
)
|
||||||
|
|
||||||
try await stream(request: request) { eventName, dataText in
|
try await stream(request: request) { eventName, dataText in
|
||||||
switch eventName {
|
try await Self.handleCompletionStreamEvent(eventName: eventName, dataText: dataText, onEvent: onEvent)
|
||||||
case "meta":
|
|
||||||
let payload: CompletionStreamMeta = try Self.decodeEvent(dataText, as: CompletionStreamMeta.self, eventName: eventName)
|
|
||||||
await onEvent(.meta(payload))
|
|
||||||
case "tool_call":
|
|
||||||
let payload: CompletionStreamToolCall = try Self.decodeEvent(dataText, as: CompletionStreamToolCall.self, eventName: eventName)
|
|
||||||
await onEvent(.toolCall(payload))
|
|
||||||
case "delta":
|
|
||||||
let payload: CompletionStreamDelta = try Self.decodeEvent(dataText, as: CompletionStreamDelta.self, eventName: eventName)
|
|
||||||
await onEvent(.delta(payload))
|
|
||||||
case "done":
|
|
||||||
do {
|
|
||||||
let payload: CompletionStreamDone = try Self.decodeEvent(dataText, as: CompletionStreamDone.self, eventName: eventName)
|
|
||||||
await onEvent(.done(payload))
|
|
||||||
} catch {
|
|
||||||
if let recovered = Self.decodeLastJSONLine(dataText, as: CompletionStreamDone.self) {
|
|
||||||
SybilLog.warning(
|
|
||||||
SybilLog.network,
|
|
||||||
"Recovered chat stream done payload from concatenated SSE data"
|
|
||||||
)
|
|
||||||
await onEvent(.done(recovered))
|
|
||||||
} else {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "error":
|
|
||||||
let payload: StreamErrorPayload = try Self.decodeEvent(dataText, as: StreamErrorPayload.self, eventName: eventName)
|
|
||||||
await onEvent(.error(payload))
|
|
||||||
default:
|
|
||||||
SybilLog.warning(SybilLog.network, "Ignoring unknown chat stream event '\(eventName)'")
|
|
||||||
await onEvent(.ignored)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SybilLog.info(SybilLog.network, "Chat stream completed")
|
SybilLog.info(SybilLog.network, "Chat stream completed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func attachCompletionStream(
|
||||||
|
chatID: String,
|
||||||
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
|
) async throws {
|
||||||
|
let request = try makeRequest(
|
||||||
|
path: "/v1/chats/\(chatID)/stream/attach",
|
||||||
|
method: "POST",
|
||||||
|
body: nil,
|
||||||
|
acceptsSSE: true
|
||||||
|
)
|
||||||
|
|
||||||
|
SybilLog.info(
|
||||||
|
SybilLog.network,
|
||||||
|
"Attaching chat stream POST \(request.url?.absoluteString ?? "<unknown>")"
|
||||||
|
)
|
||||||
|
|
||||||
|
try await stream(request: request) { eventName, dataText in
|
||||||
|
try await Self.handleCompletionStreamEvent(eventName: eventName, dataText: dataText, onEvent: onEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
SybilLog.info(SybilLog.network, "Attached chat stream completed")
|
||||||
|
}
|
||||||
|
|
||||||
func runSearchStream(
|
func runSearchStream(
|
||||||
searchID: String,
|
searchID: String,
|
||||||
body: SearchRunRequest,
|
body: SearchRunRequest,
|
||||||
@@ -188,34 +184,35 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
)
|
)
|
||||||
|
|
||||||
try await stream(request: request) { eventName, dataText in
|
try await stream(request: request) { eventName, dataText in
|
||||||
switch eventName {
|
try await Self.handleSearchStreamEvent(eventName: eventName, dataText: dataText, onEvent: onEvent)
|
||||||
case "search_results":
|
|
||||||
let payload: SearchResultsPayload = try Self.decodeEvent(dataText, as: SearchResultsPayload.self, eventName: eventName)
|
|
||||||
await onEvent(.searchResults(payload))
|
|
||||||
case "search_error":
|
|
||||||
let payload: SearchErrorPayload = try Self.decodeEvent(dataText, as: SearchErrorPayload.self, eventName: eventName)
|
|
||||||
await onEvent(.searchError(payload))
|
|
||||||
case "answer":
|
|
||||||
let payload: SearchAnswerPayload = try Self.decodeEvent(dataText, as: SearchAnswerPayload.self, eventName: eventName)
|
|
||||||
await onEvent(.answer(payload))
|
|
||||||
case "answer_error":
|
|
||||||
let payload: SearchErrorPayload = try Self.decodeEvent(dataText, as: SearchErrorPayload.self, eventName: eventName)
|
|
||||||
await onEvent(.answerError(payload))
|
|
||||||
case "done":
|
|
||||||
let payload: SearchDonePayload = try Self.decodeEvent(dataText, as: SearchDonePayload.self, eventName: eventName)
|
|
||||||
await onEvent(.done(payload))
|
|
||||||
case "error":
|
|
||||||
let payload: StreamErrorPayload = try Self.decodeEvent(dataText, as: StreamErrorPayload.self, eventName: eventName)
|
|
||||||
await onEvent(.error(payload))
|
|
||||||
default:
|
|
||||||
SybilLog.warning(SybilLog.network, "Ignoring unknown search stream event '\(eventName)'")
|
|
||||||
await onEvent(.ignored)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SybilLog.info(SybilLog.network, "Search stream completed")
|
SybilLog.info(SybilLog.network, "Search stream completed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func attachSearchStream(
|
||||||
|
searchID: String,
|
||||||
|
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
||||||
|
) async throws {
|
||||||
|
let request = try makeRequest(
|
||||||
|
path: "/v1/searches/\(searchID)/run/stream/attach",
|
||||||
|
method: "POST",
|
||||||
|
body: nil,
|
||||||
|
acceptsSSE: true
|
||||||
|
)
|
||||||
|
|
||||||
|
SybilLog.info(
|
||||||
|
SybilLog.network,
|
||||||
|
"Attaching search stream POST \(request.url?.absoluteString ?? "<unknown>")"
|
||||||
|
)
|
||||||
|
|
||||||
|
try await stream(request: request) { eventName, dataText in
|
||||||
|
try await Self.handleSearchStreamEvent(eventName: eventName, dataText: dataText, onEvent: onEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
SybilLog.info(SybilLog.network, "Attached search stream completed")
|
||||||
|
}
|
||||||
|
|
||||||
private func request<Response: Decodable>(
|
private func request<Response: Decodable>(
|
||||||
_ path: String,
|
_ path: String,
|
||||||
method: String,
|
method: String,
|
||||||
@@ -498,6 +495,75 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
return try? Self.decodeJSON(type, from: data)
|
return try? Self.decodeJSON(type, from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func handleCompletionStreamEvent(
|
||||||
|
eventName: String,
|
||||||
|
dataText: String,
|
||||||
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
|
) async throws {
|
||||||
|
switch eventName {
|
||||||
|
case "meta":
|
||||||
|
let payload: CompletionStreamMeta = try Self.decodeEvent(dataText, as: CompletionStreamMeta.self, eventName: eventName)
|
||||||
|
await onEvent(.meta(payload))
|
||||||
|
case "tool_call":
|
||||||
|
let payload: CompletionStreamToolCall = try Self.decodeEvent(dataText, as: CompletionStreamToolCall.self, eventName: eventName)
|
||||||
|
await onEvent(.toolCall(payload))
|
||||||
|
case "delta":
|
||||||
|
let payload: CompletionStreamDelta = try Self.decodeEvent(dataText, as: CompletionStreamDelta.self, eventName: eventName)
|
||||||
|
await onEvent(.delta(payload))
|
||||||
|
case "done":
|
||||||
|
do {
|
||||||
|
let payload: CompletionStreamDone = try Self.decodeEvent(dataText, as: CompletionStreamDone.self, eventName: eventName)
|
||||||
|
await onEvent(.done(payload))
|
||||||
|
} catch {
|
||||||
|
if let recovered = Self.decodeLastJSONLine(dataText, as: CompletionStreamDone.self) {
|
||||||
|
SybilLog.warning(
|
||||||
|
SybilLog.network,
|
||||||
|
"Recovered chat stream done payload from concatenated SSE data"
|
||||||
|
)
|
||||||
|
await onEvent(.done(recovered))
|
||||||
|
} else {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "error":
|
||||||
|
let payload: StreamErrorPayload = try Self.decodeEvent(dataText, as: StreamErrorPayload.self, eventName: eventName)
|
||||||
|
await onEvent(.error(payload))
|
||||||
|
default:
|
||||||
|
SybilLog.warning(SybilLog.network, "Ignoring unknown chat stream event '\(eventName)'")
|
||||||
|
await onEvent(.ignored)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func handleSearchStreamEvent(
|
||||||
|
eventName: String,
|
||||||
|
dataText: String,
|
||||||
|
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
||||||
|
) async throws {
|
||||||
|
switch eventName {
|
||||||
|
case "search_results":
|
||||||
|
let payload: SearchResultsPayload = try Self.decodeEvent(dataText, as: SearchResultsPayload.self, eventName: eventName)
|
||||||
|
await onEvent(.searchResults(payload))
|
||||||
|
case "search_error":
|
||||||
|
let payload: SearchErrorPayload = try Self.decodeEvent(dataText, as: SearchErrorPayload.self, eventName: eventName)
|
||||||
|
await onEvent(.searchError(payload))
|
||||||
|
case "answer":
|
||||||
|
let payload: SearchAnswerPayload = try Self.decodeEvent(dataText, as: SearchAnswerPayload.self, eventName: eventName)
|
||||||
|
await onEvent(.answer(payload))
|
||||||
|
case "answer_error":
|
||||||
|
let payload: SearchErrorPayload = try Self.decodeEvent(dataText, as: SearchErrorPayload.self, eventName: eventName)
|
||||||
|
await onEvent(.answerError(payload))
|
||||||
|
case "done":
|
||||||
|
let payload: SearchDonePayload = try Self.decodeEvent(dataText, as: SearchDonePayload.self, eventName: eventName)
|
||||||
|
await onEvent(.done(payload))
|
||||||
|
case "error":
|
||||||
|
let payload: StreamErrorPayload = try Self.decodeEvent(dataText, as: StreamErrorPayload.self, eventName: eventName)
|
||||||
|
await onEvent(.error(payload))
|
||||||
|
default:
|
||||||
|
SybilLog.warning(SybilLog.network, "Ignoring unknown search stream event '\(eventName)'")
|
||||||
|
await onEvent(.ignored)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static func flushSSEEvent(
|
private static func flushSSEEvent(
|
||||||
eventName: inout String,
|
eventName: inout String,
|
||||||
dataLines: inout [String]
|
dataLines: inout [String]
|
||||||
|
|||||||
@@ -13,13 +13,22 @@ protocol SybilAPIClienting: Sendable {
|
|||||||
func createChatFromSearch(searchID: String, title: String?) async throws -> ChatSummary
|
func createChatFromSearch(searchID: String, title: String?) async throws -> ChatSummary
|
||||||
func deleteSearch(searchID: String) async throws
|
func deleteSearch(searchID: String) async throws
|
||||||
func listModels() async throws -> ModelCatalogResponse
|
func listModels() async throws -> ModelCatalogResponse
|
||||||
|
func getActiveRuns() async throws -> ActiveRunsResponse
|
||||||
func runCompletionStream(
|
func runCompletionStream(
|
||||||
body: CompletionStreamRequest,
|
body: CompletionStreamRequest,
|
||||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
) async throws
|
) async throws
|
||||||
|
func attachCompletionStream(
|
||||||
|
chatID: String,
|
||||||
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
|
) async throws
|
||||||
func runSearchStream(
|
func runSearchStream(
|
||||||
searchID: String,
|
searchID: String,
|
||||||
body: SearchRunRequest,
|
body: SearchRunRequest,
|
||||||
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
||||||
) async throws
|
) async throws
|
||||||
|
func attachSearchStream(
|
||||||
|
searchID: String,
|
||||||
|
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
||||||
|
) async throws
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -354,6 +354,16 @@ public struct SearchDetail: Codable, Identifiable, Hashable, Sendable {
|
|||||||
public var results: [SearchResultItem]
|
public var results: [SearchResultItem]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct ActiveRunsResponse: Codable, Hashable, Sendable {
|
||||||
|
public var chats: [String]
|
||||||
|
public var searches: [String]
|
||||||
|
|
||||||
|
public init(chats: [String] = [], searches: [String] = []) {
|
||||||
|
self.chats = chats
|
||||||
|
self.searches = searches
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct SearchRunRequest: Codable, Sendable {
|
public struct SearchRunRequest: Codable, Sendable {
|
||||||
public var query: String?
|
public var query: String?
|
||||||
public var title: String?
|
public var title: String?
|
||||||
|
|||||||
@@ -554,6 +554,13 @@ private struct SybilPhoneSidebarRow: View {
|
|||||||
Text(item.title)
|
Text(item.title)
|
||||||
.font(.sybil(.subheadline, weight: .semibold))
|
.font(.sybil(.subheadline, weight: .semibold))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
.layoutPriority(1)
|
||||||
|
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
|
||||||
|
if item.isRunning {
|
||||||
|
SybilSidebarActivityIndicator()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
|
|||||||
@@ -104,6 +104,13 @@ struct SybilSidebarView: View {
|
|||||||
Text(item.title)
|
Text(item.title)
|
||||||
.font(.sybil(.subheadline, weight: .semibold))
|
.font(.sybil(.subheadline, weight: .semibold))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
.layoutPriority(1)
|
||||||
|
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
|
||||||
|
if item.isRunning {
|
||||||
|
SybilSidebarActivityIndicator()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
@@ -205,3 +212,15 @@ struct SybilSidebarView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct SybilSidebarActivityIndicator: View {
|
||||||
|
var body: some View {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(.circular)
|
||||||
|
.controlSize(.small)
|
||||||
|
.tint(SybilTheme.accent)
|
||||||
|
.scaleEffect(0.82)
|
||||||
|
.frame(width: 16, height: 16)
|
||||||
|
.accessibilityLabel("Generating")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -75,7 +75,7 @@ struct SybilWorkspaceView: View {
|
|||||||
guard onRequestNewChat != nil else {
|
guard onRequestNewChat != nil else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
guard !viewModel.isSending, viewModel.draftKind == nil else {
|
guard !viewModel.isActiveSelectionSending, viewModel.draftKind == nil else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
guard case .chat = viewModel.selectedItem else {
|
guard case .chat = viewModel.selectedItem else {
|
||||||
@@ -155,7 +155,7 @@ struct SybilWorkspaceView: View {
|
|||||||
workspaceContentStack
|
workspaceContentStack
|
||||||
|
|
||||||
if showsCustomWorkspaceNavigation {
|
if showsCustomWorkspaceNavigation {
|
||||||
SybilWorkspaceCharacterBackdrop(isBusy: viewModel.isSending)
|
SybilWorkspaceCharacterBackdrop(isBusy: viewModel.isActiveSelectionSending)
|
||||||
.allowsHitTesting(false)
|
.allowsHitTesting(false)
|
||||||
customWorkspaceNavigationBar
|
customWorkspaceNavigationBar
|
||||||
}
|
}
|
||||||
@@ -560,10 +560,10 @@ struct SybilWorkspaceView: View {
|
|||||||
Circle()
|
Circle()
|
||||||
.stroke(SybilTheme.border.opacity(0.82), lineWidth: 1)
|
.stroke(SybilTheme.border.opacity(0.82), lineWidth: 1)
|
||||||
)
|
)
|
||||||
.foregroundStyle(viewModel.isSending ? SybilTheme.textMuted : SybilTheme.text)
|
.foregroundStyle(viewModel.isActiveSelectionSending ? SybilTheme.textMuted : SybilTheme.text)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.disabled(viewModel.isSending)
|
.disabled(viewModel.isActiveSelectionSending)
|
||||||
.accessibilityLabel("Attach file")
|
.accessibilityLabel("Attach file")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,7 +626,7 @@ struct SybilWorkspaceView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onDrop(of: [UTType.fileURL.identifier, UTType.image.identifier], isTargeted: $isComposerDropTargeted) { providers in
|
.onDrop(of: [UTType.fileURL.identifier, UTType.image.identifier], isTargeted: $isComposerDropTargeted) { providers in
|
||||||
if viewModel.isSearchMode || viewModel.isSending {
|
if viewModel.isSearchMode || viewModel.isActiveSelectionSending {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ private struct MockClientCallSnapshot: Sendable {
|
|||||||
var listSearches = 0
|
var listSearches = 0
|
||||||
var getChat = 0
|
var getChat = 0
|
||||||
var getSearch = 0
|
var getSearch = 0
|
||||||
|
var getActiveRuns = 0
|
||||||
|
var attachCompletionStream = 0
|
||||||
|
var attachSearchStream = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct UnexpectedClientCall: Error {}
|
private struct UnexpectedClientCall: Error {}
|
||||||
@@ -18,27 +21,34 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
private let chatDetails: [String: ChatDetail]
|
private let chatDetails: [String: ChatDetail]
|
||||||
private let searchDetails: [String: SearchDetail]
|
private let searchDetails: [String: SearchDetail]
|
||||||
private let createChatResponse: ChatSummary?
|
private let createChatResponse: ChatSummary?
|
||||||
|
private let activeRunsResponse: ActiveRunsResponse
|
||||||
|
|
||||||
private var snapshot = MockClientCallSnapshot()
|
private var snapshot = MockClientCallSnapshot()
|
||||||
private var getChatDelayNanoseconds: UInt64 = 0
|
private var getChatDelayNanoseconds: UInt64 = 0
|
||||||
private var getSearchDelayNanoseconds: UInt64 = 0
|
private var getSearchDelayNanoseconds: UInt64 = 0
|
||||||
private var completionStreamNetworkErrorMessage: String?
|
private var completionStreamNetworkErrorMessage: String?
|
||||||
private var completionStreamDelayNanoseconds: UInt64 = 0
|
private var completionStreamDelayNanoseconds: UInt64 = 0
|
||||||
|
private var completionAttachEvents: [String: [CompletionStreamEvent]] = [:]
|
||||||
|
private var completionAttachDelayNanoseconds: UInt64 = 0
|
||||||
private var searchStreamNetworkErrorMessage: String?
|
private var searchStreamNetworkErrorMessage: String?
|
||||||
private var searchStreamDelayNanoseconds: UInt64 = 0
|
private var searchStreamDelayNanoseconds: UInt64 = 0
|
||||||
|
private var searchAttachEvents: [String: [SearchStreamEvent]] = [:]
|
||||||
|
private var searchAttachDelayNanoseconds: UInt64 = 0
|
||||||
|
|
||||||
init(
|
init(
|
||||||
chatsResponse: [ChatSummary] = [],
|
chatsResponse: [ChatSummary] = [],
|
||||||
searchesResponse: [SearchSummary] = [],
|
searchesResponse: [SearchSummary] = [],
|
||||||
chatDetails: [String: ChatDetail] = [:],
|
chatDetails: [String: ChatDetail] = [:],
|
||||||
searchDetails: [String: SearchDetail] = [:],
|
searchDetails: [String: SearchDetail] = [:],
|
||||||
createChatResponse: ChatSummary? = nil
|
createChatResponse: ChatSummary? = nil,
|
||||||
|
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse()
|
||||||
) {
|
) {
|
||||||
self.chatsResponse = chatsResponse
|
self.chatsResponse = chatsResponse
|
||||||
self.searchesResponse = searchesResponse
|
self.searchesResponse = searchesResponse
|
||||||
self.chatDetails = chatDetails
|
self.chatDetails = chatDetails
|
||||||
self.searchDetails = searchDetails
|
self.searchDetails = searchDetails
|
||||||
self.createChatResponse = createChatResponse
|
self.createChatResponse = createChatResponse
|
||||||
|
self.activeRunsResponse = activeRunsResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
func currentSnapshot() -> MockClientCallSnapshot {
|
func currentSnapshot() -> MockClientCallSnapshot {
|
||||||
@@ -63,6 +73,24 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
searchStreamDelayNanoseconds = delayNanoseconds
|
searchStreamDelayNanoseconds = delayNanoseconds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setCompletionAttachEvents(
|
||||||
|
chatID: String,
|
||||||
|
events: [CompletionStreamEvent],
|
||||||
|
delayNanoseconds: UInt64 = 0
|
||||||
|
) {
|
||||||
|
completionAttachEvents[chatID] = events
|
||||||
|
completionAttachDelayNanoseconds = delayNanoseconds
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSearchAttachEvents(
|
||||||
|
searchID: String,
|
||||||
|
events: [SearchStreamEvent],
|
||||||
|
delayNanoseconds: UInt64 = 0
|
||||||
|
) {
|
||||||
|
searchAttachEvents[searchID] = events
|
||||||
|
searchAttachDelayNanoseconds = delayNanoseconds
|
||||||
|
}
|
||||||
|
|
||||||
func verifySession() async throws -> AuthSession {
|
func verifySession() async throws -> AuthSession {
|
||||||
AuthSession(authenticated: true, mode: "open")
|
AuthSession(authenticated: true, mode: "open")
|
||||||
}
|
}
|
||||||
@@ -130,6 +158,11 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
ModelCatalogResponse(providers: [:])
|
ModelCatalogResponse(providers: [:])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getActiveRuns() async throws -> ActiveRunsResponse {
|
||||||
|
snapshot.getActiveRuns += 1
|
||||||
|
return activeRunsResponse
|
||||||
|
}
|
||||||
|
|
||||||
func runCompletionStream(
|
func runCompletionStream(
|
||||||
body: CompletionStreamRequest,
|
body: CompletionStreamRequest,
|
||||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
@@ -143,6 +176,20 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
throw UnexpectedClientCall()
|
throw UnexpectedClientCall()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func attachCompletionStream(
|
||||||
|
chatID: String,
|
||||||
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
|
) async throws {
|
||||||
|
snapshot.attachCompletionStream += 1
|
||||||
|
let events = completionAttachEvents[chatID] ?? []
|
||||||
|
for event in events {
|
||||||
|
await onEvent(event)
|
||||||
|
}
|
||||||
|
if completionAttachDelayNanoseconds > 0 {
|
||||||
|
try await Task.sleep(nanoseconds: completionAttachDelayNanoseconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func runSearchStream(
|
func runSearchStream(
|
||||||
searchID: String,
|
searchID: String,
|
||||||
body: SearchRunRequest,
|
body: SearchRunRequest,
|
||||||
@@ -156,6 +203,20 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
}
|
}
|
||||||
throw UnexpectedClientCall()
|
throw UnexpectedClientCall()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func attachSearchStream(
|
||||||
|
searchID: String,
|
||||||
|
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
||||||
|
) async throws {
|
||||||
|
snapshot.attachSearchStream += 1
|
||||||
|
let events = searchAttachEvents[searchID] ?? []
|
||||||
|
for event in events {
|
||||||
|
await onEvent(event)
|
||||||
|
}
|
||||||
|
if searchAttachDelayNanoseconds > 0 {
|
||||||
|
try await Task.sleep(nanoseconds: searchAttachDelayNanoseconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -409,6 +470,59 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
|||||||
await sendTask.value
|
await sendTask.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func reconnectAttachesSelectedActiveChatStream() async throws {
|
||||||
|
let date = Date(timeIntervalSince1970: 1_700_000_260)
|
||||||
|
let chat = makeChatSummary(id: "chat-active", date: date)
|
||||||
|
let detail = makeChatDetail(id: "chat-active", date: date, body: "existing transcript")
|
||||||
|
let client = MockSybilClient(
|
||||||
|
chatsResponse: [chat],
|
||||||
|
chatDetails: ["chat-active": detail],
|
||||||
|
activeRunsResponse: ActiveRunsResponse(chats: ["chat-active"])
|
||||||
|
)
|
||||||
|
await client.setCompletionAttachEvents(
|
||||||
|
chatID: "chat-active",
|
||||||
|
events: [.delta(CompletionStreamDelta(text: "streaming"))],
|
||||||
|
delayNanoseconds: 100_000_000
|
||||||
|
)
|
||||||
|
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||||
|
|
||||||
|
await viewModel.reconnect()
|
||||||
|
try await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
|
||||||
|
let snapshot = await client.currentSnapshot()
|
||||||
|
#expect(snapshot.getActiveRuns >= 1)
|
||||||
|
#expect(snapshot.attachCompletionStream == 1)
|
||||||
|
#expect(viewModel.sidebarItems.first?.isRunning == true)
|
||||||
|
#expect(viewModel.isSendingVisibleChat)
|
||||||
|
#expect(viewModel.displayedMessages.last?.content == "streaming")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func activeRunOnDifferentChatDoesNotDisableComposer() async throws {
|
||||||
|
let date = Date(timeIntervalSince1970: 1_700_000_270)
|
||||||
|
let activeChat = makeChatSummary(id: "chat-active", date: date)
|
||||||
|
let idleChat = makeChatSummary(id: "chat-idle", date: date.addingTimeInterval(1))
|
||||||
|
let client = MockSybilClient(
|
||||||
|
chatsResponse: [idleChat, activeChat],
|
||||||
|
chatDetails: [
|
||||||
|
"chat-active": makeChatDetail(id: "chat-active", date: date, body: "active transcript"),
|
||||||
|
"chat-idle": makeChatDetail(id: "chat-idle", date: date, body: "idle transcript")
|
||||||
|
],
|
||||||
|
activeRunsResponse: ActiveRunsResponse(chats: ["chat-active"])
|
||||||
|
)
|
||||||
|
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||||
|
viewModel.selectedItem = .chat("chat-idle")
|
||||||
|
viewModel.composer = "new message"
|
||||||
|
|
||||||
|
await viewModel.reconnect()
|
||||||
|
|
||||||
|
#expect(viewModel.selectedItem == .chat("chat-idle"))
|
||||||
|
#expect(viewModel.sidebarItems.first(where: { $0.selection == .chat("chat-active") })?.isRunning == true)
|
||||||
|
#expect(!viewModel.isActiveSelectionSending)
|
||||||
|
#expect(viewModel.canSendComposer)
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Test func backgroundChatStreamInterruptionIsSuppressedUntilForegroundRefresh() async throws {
|
@Test func backgroundChatStreamInterruptionIsSuppressedUntilForegroundRefresh() async throws {
|
||||||
let date = Date(timeIntervalSince1970: 1_700_000_300)
|
let date = Date(timeIntervalSince1970: 1_700_000_300)
|
||||||
|
|||||||
Reference in New Issue
Block a user