This commit is contained in:
@@ -53,6 +53,7 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
private var lastCreateChatCall: ChatCreateCallSnapshot?
|
||||
private var lastQuickQuestionStreamBody: QuickQuestionStreamRequest?
|
||||
private var completionStreamEvents: [CompletionStreamEvent]?
|
||||
private var completionStreamPostEventNetworkErrorMessage: String?
|
||||
private var listChatsDelayNanoseconds: UInt64 = 0
|
||||
private var listSearchesDelayNanoseconds: UInt64 = 0
|
||||
private var getChatDelayNanoseconds: UInt64 = 0
|
||||
@@ -61,6 +62,7 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
private var completionStreamDelayNanoseconds: UInt64 = 0
|
||||
private var completionAttachEvents: [String: [CompletionStreamEvent]] = [:]
|
||||
private var completionAttachDelayNanoseconds: UInt64 = 0
|
||||
private var chatDetailResponseSequences: [String: [ChatDetail]] = [:]
|
||||
private var searchStreamNetworkErrorMessage: String?
|
||||
private var searchStreamDelayNanoseconds: UInt64 = 0
|
||||
private var searchAttachEvents: [String: [SearchStreamEvent]] = [:]
|
||||
@@ -113,6 +115,10 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
completionStreamDelayNanoseconds = delayNanoseconds
|
||||
}
|
||||
|
||||
func setCompletionStreamPostEventNetworkError(_ message: String) {
|
||||
completionStreamPostEventNetworkErrorMessage = message
|
||||
}
|
||||
|
||||
func setCompletionStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) {
|
||||
completionStreamNetworkErrorMessage = message
|
||||
completionStreamDelayNanoseconds = delayNanoseconds
|
||||
@@ -127,6 +133,10 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
getChatDelayNanoseconds = delayNanoseconds
|
||||
}
|
||||
|
||||
func setChatDetailResponses(chatID: String, responses: [ChatDetail]) {
|
||||
chatDetailResponseSequences[chatID] = responses
|
||||
}
|
||||
|
||||
func setGetSearchDelay(_ delayNanoseconds: UInt64) {
|
||||
getSearchDelayNanoseconds = delayNanoseconds
|
||||
}
|
||||
@@ -199,6 +209,11 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
if getChatDelayNanoseconds > 0 {
|
||||
try await Task.sleep(nanoseconds: getChatDelayNanoseconds)
|
||||
}
|
||||
if var responses = chatDetailResponseSequences[chatID], !responses.isEmpty {
|
||||
let response = responses.removeFirst()
|
||||
chatDetailResponseSequences[chatID] = responses
|
||||
return response
|
||||
}
|
||||
guard let detail = chatDetails[chatID] else {
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
@@ -319,6 +334,9 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
for event in completionStreamEvents {
|
||||
await onEvent(event)
|
||||
}
|
||||
if let completionStreamPostEventNetworkErrorMessage {
|
||||
throw APIError.networkError(message: completionStreamPostEventNetworkErrorMessage)
|
||||
}
|
||||
return
|
||||
}
|
||||
throw UnexpectedClientCall()
|
||||
@@ -457,6 +475,25 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
||||
)
|
||||
}
|
||||
|
||||
private func makeCompletionToolCall(
|
||||
id: String,
|
||||
status: String,
|
||||
summary: String = "Searched the web"
|
||||
) -> CompletionStreamToolCall {
|
||||
CompletionStreamToolCall(
|
||||
toolCallId: id,
|
||||
name: "web_search",
|
||||
status: status,
|
||||
summary: summary,
|
||||
args: ["query": .string("Sybil streaming traces")],
|
||||
startedAt: "2026-08-30T12:00:00.000Z",
|
||||
completedAt: status == "initiated" ? nil : "2026-08-30T12:00:00.120Z",
|
||||
durationMs: status == "initiated" ? nil : 120,
|
||||
error: nil,
|
||||
resultPreview: status == "initiated" ? nil : "{\"ok\":true}"
|
||||
)
|
||||
}
|
||||
|
||||
@Test func chatForkMetadataDecodesBackwardCompatiblyAndSurvivesWorkspaceConversions() throws {
|
||||
let decoder = JSONDecoder()
|
||||
let legacySummary = try decoder.decode(
|
||||
@@ -543,6 +580,63 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
||||
#expect(toolMessage.id == "tool-a")
|
||||
}
|
||||
|
||||
@Test func streamingAssistantStatePromotesAndReplacesEphemeralTraces() {
|
||||
var state = StreamingAssistantState()
|
||||
|
||||
#expect(state.receiveDelta(" I'll search ") == .replace(" I'll search "))
|
||||
#expect(state.receiveDelta("for that. ") == .replace(" I'll search for that. "))
|
||||
#expect(state.receiveToolCall(id: "call-1", status: "INITIATED") == .replace(""))
|
||||
#expect(state.segmentText == "")
|
||||
#expect(state.ephemeralTrace == "I'll search for that.")
|
||||
#expect(state.traceRevision == 1)
|
||||
|
||||
#expect(state.receiveToolCall(id: "call-1", status: "completed") == .unchanged)
|
||||
#expect(state.receiveToolCall(id: "call-1", status: "initiated") == .unchanged)
|
||||
#expect(state.ephemeralTrace == "I'll search for that.")
|
||||
#expect(state.traceRevision == 1)
|
||||
|
||||
#expect(state.receiveToolCall(id: "parallel-call", status: "initiated") == .replace(""))
|
||||
#expect(state.ephemeralTrace == "I'll search for that.")
|
||||
#expect(state.traceRevision == 1)
|
||||
|
||||
#expect(state.receiveDelta("I need one more source.") == .replace("I need one more source."))
|
||||
#expect(state.ephemeralTrace == nil)
|
||||
#expect(state.receiveToolCall(id: "call-2", status: "initiated") == .replace(""))
|
||||
#expect(state.ephemeralTrace == "I need one more source.")
|
||||
#expect(state.traceRevision == 2)
|
||||
|
||||
#expect(state.receiveDelta("Final answer") == .replace("Final answer"))
|
||||
#expect(state.ephemeralTrace == nil)
|
||||
#expect(state.receiveDone("Canonical final answer.") == .replace("Canonical final answer."))
|
||||
#expect(state.segmentText == "")
|
||||
#expect(state.ephemeralTrace == nil)
|
||||
}
|
||||
|
||||
@Test func streamingAssistantErrorClearsTraceWithoutReplacingPartialContent() {
|
||||
var traceState = StreamingAssistantState()
|
||||
#expect(traceState.receiveDelta("I'll inspect that.") == .replace("I'll inspect that."))
|
||||
#expect(traceState.receiveToolCall(id: "inspect", status: "initiated") == .replace(""))
|
||||
#expect(traceState.ephemeralTrace == "I'll inspect that.")
|
||||
#expect(traceState.receiveError() == .unchanged)
|
||||
#expect(traceState.segmentText.isEmpty)
|
||||
#expect(traceState.ephemeralTrace == nil)
|
||||
|
||||
var partialState = StreamingAssistantState()
|
||||
var renderedContent = ""
|
||||
if case let .replace(content) = partialState.receiveDelta("Partial answer") {
|
||||
renderedContent = content
|
||||
}
|
||||
let errorUpdate = partialState.receiveError()
|
||||
if case let .replace(content) = errorUpdate {
|
||||
renderedContent = content
|
||||
}
|
||||
|
||||
#expect(errorUpdate == .unchanged)
|
||||
#expect(renderedContent == "Partial answer")
|
||||
#expect(partialState.segmentText.isEmpty)
|
||||
#expect(partialState.ephemeralTrace == nil)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func normalizedAPIBaseURLPreservesExplicitAPIPath() async throws {
|
||||
let defaults = UserDefaults(suiteName: #function)!
|
||||
@@ -848,6 +942,29 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
||||
#expect(!viewModel.isLoadingSelection)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func failedFirstSendClearsPendingStateMigratedToCreatedChat() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_235)
|
||||
let createdChat = makeChatSummary(id: "chat-created-before-failure", date: date)
|
||||
let client = MockSybilClient(createChatResponse: createdChat)
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = true
|
||||
viewModel.isCheckingSession = false
|
||||
viewModel.startNewChat()
|
||||
viewModel.model = ""
|
||||
viewModel.composer = "This send should fail after chat creation"
|
||||
|
||||
await viewModel.sendComposer()
|
||||
|
||||
let snapshot = await client.currentSnapshot()
|
||||
#expect(snapshot.createChat == 1)
|
||||
#expect(snapshot.runCompletionStream == 0)
|
||||
#expect(viewModel.selectedItem == .chat(createdChat.id))
|
||||
#expect(viewModel.selectedChat?.id == createdChat.id)
|
||||
#expect(viewModel.displayedMessages.isEmpty)
|
||||
#expect(viewModel.displayedStreamingAssistantPresentation == nil)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func newDraftChatDoesNotShowTypingStateFromPreviousSend() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_240)
|
||||
@@ -1072,6 +1189,60 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
||||
#expect(!viewModel.isQuickQuestionSending)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func quickQuestionKeepsToolNarrationEphemeralAndFinalizesCanonicalAnswer() async throws {
|
||||
let client = MockSybilClient()
|
||||
await client.setCompletionStreamEvents([
|
||||
.delta(CompletionStreamDelta(text: "I'll search for that.")),
|
||||
.toolCall(makeCompletionToolCall(id: "quick-search", status: "initiated")),
|
||||
.toolCall(makeCompletionToolCall(id: "quick-search", status: "completed")),
|
||||
.delta(CompletionStreamDelta(text: "The streamed answer")),
|
||||
.done(CompletionStreamDone(text: "The canonical answer."))
|
||||
])
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = true
|
||||
viewModel.isCheckingSession = false
|
||||
viewModel.quickQuestionPrompt = "What changed?"
|
||||
|
||||
let task = viewModel.sendQuickQuestion()
|
||||
await task?.value
|
||||
|
||||
let toolMessages = viewModel.quickQuestionMessages.filter { $0.toolCallMetadata != nil }
|
||||
let assistantMessage = try #require(viewModel.quickQuestionMessages.last(where: { $0.role == .assistant }))
|
||||
#expect(toolMessages.count == 1)
|
||||
#expect(toolMessages.first?.toolCallMetadata?.status == "completed")
|
||||
#expect(assistantMessage.content == "The canonical answer.")
|
||||
#expect(!assistantMessage.content.contains("I'll search"))
|
||||
#expect(viewModel.quickQuestionAnswerText == "The canonical answer.")
|
||||
#expect(viewModel.quickQuestionStreamingAssistantPresentation?.ephemeralTrace == nil)
|
||||
#expect(!viewModel.isQuickQuestionSending)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func quickQuestionTransportErrorClearsTraceAndPreservesPartialAnswer() async throws {
|
||||
let client = MockSybilClient()
|
||||
await client.setCompletionStreamEvents([
|
||||
.delta(CompletionStreamDelta(text: "I'll inspect that.")),
|
||||
.toolCall(makeCompletionToolCall(id: "quick-inspect", status: "initiated")),
|
||||
.delta(CompletionStreamDelta(text: "Partial answer"))
|
||||
])
|
||||
await client.setCompletionStreamPostEventNetworkError("connection lost")
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = true
|
||||
viewModel.isCheckingSession = false
|
||||
viewModel.quickQuestionPrompt = "What changed?"
|
||||
|
||||
let task = viewModel.sendQuickQuestion()
|
||||
await task?.value
|
||||
|
||||
let assistantMessage = try #require(viewModel.quickQuestionMessages.last(where: { $0.role == .assistant }))
|
||||
#expect(assistantMessage.content == "Partial answer")
|
||||
#expect(viewModel.quickQuestionAnswerText == "Partial answer")
|
||||
#expect(viewModel.quickQuestionStreamingAssistantPresentation?.ephemeralTrace == nil)
|
||||
#expect(viewModel.quickQuestionError != nil)
|
||||
#expect(!viewModel.isQuickQuestionSending)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func quickQuestionConvertCreatesSeededChat() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_250)
|
||||
@@ -1184,6 +1355,44 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
||||
#expect(viewModel.displayedMessages.last?.content == "streaming")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func completedAttachKeepsFinalContentVisibleUntilCanonicalChatLoads() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_265)
|
||||
let chat = makeChatSummary(id: "chat-active-complete", date: date)
|
||||
let baseDetail = makeChatDetail(id: chat.id, date: date, body: "base transcript")
|
||||
let canonicalDetail = makeChatDetail(id: chat.id, date: date, body: "canonical attached response")
|
||||
let client = MockSybilClient(
|
||||
chatsResponse: [chat],
|
||||
chatDetails: [chat.id: canonicalDetail],
|
||||
activeRunsResponse: ActiveRunsResponse(chats: [chat.id])
|
||||
)
|
||||
await client.setChatDetailResponses(chatID: chat.id, responses: [baseDetail, canonicalDetail])
|
||||
await client.setGetChatDelay(80_000_000)
|
||||
await client.setCompletionAttachEvents(
|
||||
chatID: chat.id,
|
||||
events: [.done(CompletionStreamDone(text: "attached response"))]
|
||||
)
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
|
||||
await viewModel.reconnect()
|
||||
|
||||
var snapshot = await client.currentSnapshot()
|
||||
for _ in 0..<80 {
|
||||
guard snapshot.getChat < 2 else { break }
|
||||
try await Task.sleep(nanoseconds: 5_000_000)
|
||||
snapshot = await client.currentSnapshot()
|
||||
}
|
||||
|
||||
#expect(snapshot.getChat >= 2)
|
||||
#expect(viewModel.displayedMessages.last?.content == "attached response")
|
||||
|
||||
for _ in 0..<80 {
|
||||
guard viewModel.displayedMessages.last?.content != "canonical attached response" else { break }
|
||||
try await Task.sleep(nanoseconds: 5_000_000)
|
||||
}
|
||||
#expect(viewModel.displayedMessages.last?.content == "canonical attached response")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func activeRunOnDifferentChatDoesNotDisableComposer() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_270)
|
||||
|
||||
Reference in New Issue
Block a user