Polish streaming thinking traces
TestFlight / Build and upload (push) Successful in 1m41s

This commit is contained in:
2026-08-30 15:26:31 -07:00
parent 922172fc60
commit 26d917e96a
10 changed files with 1128 additions and 122 deletions
@@ -5,6 +5,7 @@ struct SybilChatTranscriptView: View {
var messages: [Message]
var isLoading: Bool
var isSending: Bool
var streamingAssistantPresentation: StreamingAssistantPresentation?
var topContentInset: CGFloat = 0
var bottomContentInset: CGFloat = 0
var bottomPinRequestID: Int = 0
@@ -41,7 +42,11 @@ struct SybilChatTranscriptView: View {
ForEach(renderItems) { item in
switch item {
case let .message(message):
MessageBubble(message: message, isSending: isSending)
MessageBubble(
message: message,
isSending: isSending,
streamingAssistantPresentation: streamingAssistantPresentation
)
.frame(maxWidth: .infinity)
case let .toolGroup(id, messages):
ToolCallStackView(
@@ -140,6 +145,7 @@ func buildTranscriptRenderItems(from messages: [Message]) -> [TranscriptRenderIt
private struct MessageBubble: View {
var message: Message
var isSending: Bool
var streamingAssistantPresentation: StreamingAssistantPresentation?
private var toolCallMetadata: ToolCallMetadata? {
message.toolCallMetadata
@@ -149,10 +155,13 @@ private struct MessageBubble: View {
message.role == .user
}
private var isPendingAssistant: Bool {
message.id.hasPrefix("temp-assistant-") &&
isSending &&
message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
private var isStreamingAssistant: Bool {
message.role == .assistant && message.id.hasPrefix("temp-assistant-")
}
private var matchingStreamingAssistantPresentation: StreamingAssistantPresentation? {
guard streamingAssistantPresentation?.messageID == message.id else { return nil }
return streamingAssistantPresentation
}
var body: some View {
@@ -174,16 +183,12 @@ private struct MessageBubble: View {
)
}
if isPendingAssistant {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
.tint(SybilTheme.primary)
Text("Thinking…")
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted)
}
.padding(.vertical, 2)
if isStreamingAssistant {
StreamingAssistantContentView(
content: message.content,
isSending: isSending,
presentation: matchingStreamingAssistantPresentation
)
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Markdown(message.content)
.tint(SybilTheme.primary)
@@ -232,6 +237,123 @@ private struct MessageBubble: View {
}
}
private struct StreamingTraceHeightPreferenceKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
struct StreamingAssistantContentView: View {
private enum Phase: Hashable {
case pending
case trace(Int)
case answer
}
var content: String
var isSending: Bool
var presentation: StreamingAssistantPresentation?
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var retainedTraceHeight: CGFloat = 0
private var traceText: String? {
guard let trace = presentation?.ephemeralTrace?.trimmingCharacters(in: .whitespacesAndNewlines),
!trace.isEmpty
else {
return nil
}
return trace
}
private var hasAnswer: Bool {
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private var phase: Phase {
if hasAnswer {
return .answer
}
if traceText != nil {
return .trace(presentation?.traceRevision ?? 0)
}
return .pending
}
private var traceTransition: AnyTransition {
guard !reduceMotion else { return .identity }
return .asymmetric(
insertion: .opacity.combined(with: .offset(y: 5)),
removal: .opacity.combined(with: .offset(y: -5))
)
}
private var answerTransition: AnyTransition {
guard !reduceMotion else { return .identity }
return .opacity.combined(with: .offset(y: 3))
}
var body: some View {
ZStack(alignment: .topLeading) {
if let traceText {
Text(traceText)
.font(.sybil(size: 15))
.italic()
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
.lineSpacing(5)
.fixedSize(horizontal: false, vertical: true)
.opacity(0.78)
.textSelection(.disabled)
.allowsHitTesting(false)
.accessibilityLabel("Assistant is working")
.background {
GeometryReader { geometry in
Color.clear.preference(
key: StreamingTraceHeightPreferenceKey.self,
value: geometry.size.height
)
}
}
.id("thinking-trace-\(presentation?.traceRevision ?? 0)")
.transition(traceTransition)
.zIndex(1)
}
if hasAnswer {
Markdown(content)
.tint(SybilTheme.primary)
.foregroundStyle(SybilTheme.text.opacity(0.95))
.markdownTheme(.sybilReadable)
.textSelection(.enabled)
.id("streaming-assistant-answer")
.transition(answerTransition)
.zIndex(2)
}
if isSending && !hasAnswer && traceText == nil {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
.tint(SybilTheme.primary)
Text("Thinking…")
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted)
}
.padding(.vertical, 2)
.transition(reduceMotion ? .identity : .opacity)
}
}
.frame(maxWidth: .infinity, minHeight: max(24, retainedTraceHeight), alignment: .topLeading)
.onPreferenceChange(StreamingTraceHeightPreferenceKey.self) { measuredHeight in
guard measuredHeight.isFinite, measuredHeight > retainedTraceHeight else { return }
retainedTraceHeight = measuredHeight
}
.animation(reduceMotion ? nil : .easeOut(duration: 0.20), value: phase)
}
}
private struct ToolCallStackView: View {
private struct CardLayout {
var x: CGFloat
@@ -62,7 +62,11 @@ struct SybilQuickQuestionView: View {
VStack(alignment: .leading, spacing: 12) {
if hasAnswerContent {
ForEach(viewModel.quickQuestionMessages) { message in
QuickQuestionMessageView(message: message, isSending: viewModel.isQuickQuestionSending)
QuickQuestionMessageView(
message: message,
isSending: viewModel.isQuickQuestionSending,
streamingAssistantPresentation: viewModel.quickQuestionStreamingAssistantPresentation
)
}
if let error = viewModel.quickQuestionError {
@@ -263,11 +267,11 @@ private struct QuickQuestionPickerPill: View {
private struct QuickQuestionMessageView: View {
var message: Message
var isSending: Bool
var streamingAssistantPresentation: StreamingAssistantPresentation?
private var isPendingAssistant: Bool {
message.id.hasPrefix("temp-assistant-quick-") &&
isSending &&
message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
private var matchingStreamingAssistantPresentation: StreamingAssistantPresentation? {
guard streamingAssistantPresentation?.messageID == message.id else { return nil }
return streamingAssistantPresentation
}
var body: some View {
@@ -276,15 +280,12 @@ private struct QuickQuestionMessageView: View {
.font(.caption)
.foregroundStyle(SybilTheme.textMuted)
.fixedSize(horizontal: false, vertical: true)
} else if isPendingAssistant {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
.tint(SybilTheme.primary)
Text("Thinking...")
.font(.caption)
.foregroundStyle(SybilTheme.textMuted)
}
} else if message.id.hasPrefix("temp-assistant-quick-") {
StreamingAssistantContentView(
content: message.content,
isSending: isSending,
presentation: matchingStreamingAssistantPresentation
)
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Markdown(message.content)
.font(.body)
@@ -40,9 +40,72 @@ struct SidebarItem: Identifiable, Hashable {
var isRunning: Bool
}
enum StreamingAssistantContentUpdate: Equatable, Sendable {
case unchanged
case replace(String)
}
struct StreamingAssistantState: Equatable, Sendable {
private(set) var segmentText = ""
private(set) var initiatedToolCallIDs: Set<String> = []
private(set) var ephemeralTrace: String?
private(set) var traceRevision = 0
mutating func receiveDelta(_ text: String) -> StreamingAssistantContentUpdate {
guard !text.isEmpty else { return .unchanged }
segmentText += text
ephemeralTrace = nil
return .replace(segmentText)
}
mutating func receiveToolCall(id: String, status: String) -> StreamingAssistantContentUpdate {
guard status.lowercased() == "initiated", initiatedToolCallIDs.insert(id).inserted else {
return .unchanged
}
let trace = segmentText.trimmingCharacters(in: .whitespacesAndNewlines)
segmentText = ""
if !trace.isEmpty {
ephemeralTrace = trace
traceRevision += 1
}
return .replace("")
}
mutating func receiveDone(_ text: String) -> StreamingAssistantContentUpdate {
segmentText = ""
ephemeralTrace = nil
return .replace(text)
}
mutating func receiveError() -> StreamingAssistantContentUpdate {
segmentText = ""
ephemeralTrace = nil
return .unchanged
}
}
struct StreamingAssistantPresentation: Equatable, Sendable {
var messageID: String
var ephemeralTrace: String?
var traceRevision: Int
}
private struct PendingChatState {
var chatID: String?
var messages: [Message]
var streamingAssistantState = StreamingAssistantState()
var streamingAssistantPresentation: StreamingAssistantPresentation? {
guard let messageID = messages.last(where: { $0.id.hasPrefix("temp-assistant-") })?.id else {
return nil
}
return StreamingAssistantPresentation(
messageID: messageID,
ephemeralTrace: streamingAssistantState.ephemeralTrace,
traceRevision: streamingAssistantState.traceRevision
)
}
}
private enum ActiveSendContext: Hashable {
@@ -125,6 +188,7 @@ final class SybilViewModel {
var quickQuestionSubmittedModel: String?
var isQuickQuestionSending = false
var isConvertingQuickQuestion = false
private var quickQuestionStreamingAssistantState = StreamingAssistantState()
@ObservationIgnored
private var hasBootstrapped = false
@@ -360,6 +424,30 @@ final class SybilViewModel {
return canonical
}
var displayedStreamingAssistantPresentation: StreamingAssistantPresentation? {
if case let .chat(chatID) = selectedItem,
let pending = pendingChatStates[chatID] {
return pending.streamingAssistantPresentation
}
if draftKind == .chat, let pending = pendingDraftChatState {
return pending.streamingAssistantPresentation
}
return nil
}
var quickQuestionStreamingAssistantPresentation: StreamingAssistantPresentation? {
guard let messageID = quickQuestionMessages.last(where: { $0.id.hasPrefix("temp-assistant-quick-") })?.id else {
return nil
}
return StreamingAssistantPresentation(
messageID: messageID,
ephemeralTrace: quickQuestionStreamingAssistantState.ephemeralTrace,
traceRevision: quickQuestionStreamingAssistantState.traceRevision
)
}
var displayedSearch: SearchDetail? {
if case let .search(searchID) = selectedItem,
let activeSearch = activeSearchDetails[searchID] {
@@ -620,6 +708,7 @@ final class SybilViewModel {
quickQuestionTask = nil
quickQuestionRunID = nil
isQuickQuestionSending = false
quickQuestionStreamingAssistantState = StreamingAssistantState()
}
@discardableResult
@@ -1140,6 +1229,7 @@ final class SybilViewModel {
quickQuestionSubmittedPrompt = prompt
quickQuestionSubmittedProvider = provider
quickQuestionSubmittedModel = model
quickQuestionStreamingAssistantState = StreamingAssistantState()
quickQuestionMessages = [
Message(
id: "temp-assistant-quick-\(UUID().uuidString)",
@@ -1184,6 +1274,8 @@ final class SybilViewModel {
return
}
let update = quickQuestionStreamingAssistantState.receiveError()
applyQuickQuestionAssistantContentUpdate(update)
quickQuestionError = normalizeAPIError(error)
SybilLog.error(SybilLog.ui, "Quick question failed", error: error)
}
@@ -1198,17 +1290,16 @@ final class SybilViewModel {
upsertQuickQuestionToolCallMessage(payload)
case let .delta(payload):
guard !payload.text.isEmpty else { return }
mutateQuickQuestionAssistantMessage { existing in
existing + payload.text
}
let update = quickQuestionStreamingAssistantState.receiveDelta(payload.text)
applyQuickQuestionAssistantContentUpdate(update)
case let .done(payload):
mutateQuickQuestionAssistantMessage { _ in
payload.text
}
let update = quickQuestionStreamingAssistantState.receiveDone(payload.text)
applyQuickQuestionAssistantContentUpdate(update)
case let .error(payload):
let update = quickQuestionStreamingAssistantState.receiveError()
applyQuickQuestionAssistantContentUpdate(update)
await streamStatus.setError(payload.message)
case .ignored:
@@ -1607,21 +1698,28 @@ final class SybilViewModel {
}
serverActiveChatIDs.remove(chatID)
pendingChatStates[chatID] = nil
await refreshCollections(preferredSelection: selectedItem, refreshSelection: false, attachVisibleActiveRun: false)
if selectedItem == selection, draftKind == nil {
selectedChat = try await client.getChat(chatID: chatID)
selectedSearch = nil
let refreshedChat = try await client.getChat(chatID: chatID)
if selectedItem == selection, draftKind == nil {
selectedChat = refreshedChat
selectedSearch = nil
}
}
pendingChatStates[chatID] = nil
} catch {
serverActiveChatIDs.remove(chatID)
pendingChatStates[chatID] = nil
defer {
pendingChatStates[chatID] = nil
}
if isCancellation(error) {
return
}
applyPendingAssistantError(chatID: chatID)
if isActiveStreamNotFound(error) {
SybilLog.info(SybilLog.app, "Active chat stream \(chatID) no longer exists")
} else if shouldSuppressInactiveTransportError(error) {
@@ -1635,8 +1733,11 @@ final class SybilViewModel {
if selectedItem == selection, draftKind == nil {
do {
selectedChat = try await client().getChat(chatID: chatID)
selectedSearch = nil
let refreshedChat = try await client().getChat(chatID: chatID)
if selectedItem == selection, draftKind == nil {
selectedChat = refreshedChat
selectedSearch = nil
}
} catch {
SybilLog.warning(SybilLog.app, "Chat refresh after attach failure failed: \(SybilLog.describe(error))")
}
@@ -1856,6 +1957,13 @@ final class SybilViewModel {
} else {
pendingDraftChatState = PendingChatState(chatID: nil, messages: optimisticMessages)
}
defer {
if let resolvedChatID = chatID {
pendingChatStates[resolvedChatID] = nil
} else {
clearPendingChatState(for: sendContext)
}
}
requestChatBottomPin()
if chatID == nil {
@@ -2045,17 +2153,13 @@ final class SybilViewModel {
upsertPendingToolCallMessage(payload, chatID: chatID)
case let .delta(payload):
guard !payload.text.isEmpty else { return }
mutatePendingAssistantMessage(chatID: chatID) { existing in
existing + payload.text
}
applyPendingAssistantDelta(payload.text, chatID: chatID)
case let .done(payload):
mutatePendingAssistantMessage(chatID: chatID) { _ in
payload.text
}
finalizePendingAssistant(payload.text, chatID: chatID)
case let .error(payload):
applyPendingAssistantError(chatID: chatID)
await streamStatus.setError(payload.message)
case .ignored:
@@ -2233,29 +2337,68 @@ final class SybilViewModel {
}
}
private func mutatePendingAssistantMessage(chatID: String, _ transform: (String) -> String) {
private func applyPendingAssistantDelta(_ text: String, chatID: String) {
guard var pending = pendingChatStates[chatID], !pending.messages.isEmpty else {
return
}
let index = pending.messages.indices.last { pending.messages[$0].id.hasPrefix("temp-assistant-") }
guard let index else {
return
}
var message = pending.messages[index]
message.content = transform(message.content)
pending.messages[index] = message
let update = pending.streamingAssistantState.receiveDelta(text)
applyStreamingAssistantContentUpdate(
update,
to: &pending.messages,
assistantMessagePrefix: "temp-assistant-"
)
pendingChatStates[chatID] = pending
}
private func mutateQuickQuestionAssistantMessage(_ transform: (String) -> String) {
let index = quickQuestionMessages.indices.last { quickQuestionMessages[$0].id.hasPrefix("temp-assistant-quick-") }
guard let index else {
private func finalizePendingAssistant(_ text: String, chatID: String) {
guard var pending = pendingChatStates[chatID], !pending.messages.isEmpty else {
return
}
quickQuestionMessages[index].content = transform(quickQuestionMessages[index].content)
let update = pending.streamingAssistantState.receiveDone(text)
applyStreamingAssistantContentUpdate(
update,
to: &pending.messages,
assistantMessagePrefix: "temp-assistant-"
)
pendingChatStates[chatID] = pending
}
private func applyPendingAssistantError(chatID: String) {
guard var pending = pendingChatStates[chatID] else {
return
}
let update = pending.streamingAssistantState.receiveError()
applyStreamingAssistantContentUpdate(
update,
to: &pending.messages,
assistantMessagePrefix: "temp-assistant-"
)
pendingChatStates[chatID] = pending
}
private func applyQuickQuestionAssistantContentUpdate(_ update: StreamingAssistantContentUpdate) {
applyStreamingAssistantContentUpdate(
update,
to: &quickQuestionMessages,
assistantMessagePrefix: "temp-assistant-quick-"
)
}
private func applyStreamingAssistantContentUpdate(
_ update: StreamingAssistantContentUpdate,
to messages: inout [Message],
assistantMessagePrefix: String
) {
guard case let .replace(content) = update,
let index = messages.indices.last(where: { messages[$0].id.hasPrefix(assistantMessagePrefix) })
else {
return
}
messages[index].content = content
}
private func upsertPendingToolCallMessage(_ payload: CompletionStreamToolCall, chatID: String) {
@@ -2263,6 +2406,16 @@ final class SybilViewModel {
return
}
let update = pending.streamingAssistantState.receiveToolCall(
id: payload.toolCallId,
status: payload.status
)
applyStreamingAssistantContentUpdate(
update,
to: &pending.messages,
assistantMessagePrefix: "temp-assistant-"
)
if let existingIndex = pending.messages.firstIndex(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId || $0.id == "temp-tool-\(payload.toolCallId)" }) {
pending.messages[existingIndex] = toolCallMessage(for: payload, id: pending.messages[existingIndex].id)
pendingChatStates[chatID] = pending
@@ -2281,6 +2434,12 @@ final class SybilViewModel {
}
private func upsertQuickQuestionToolCallMessage(_ payload: CompletionStreamToolCall) {
let update = quickQuestionStreamingAssistantState.receiveToolCall(
id: payload.toolCallId,
status: payload.status
)
applyQuickQuestionAssistantContentUpdate(update)
if let existingIndex = quickQuestionMessages.firstIndex(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId || $0.id == "temp-tool-\(payload.toolCallId)" }) {
quickQuestionMessages[existingIndex] = toolCallMessage(for: payload, id: quickQuestionMessages[existingIndex].id)
return
@@ -2430,7 +2589,8 @@ final class SybilViewModel {
private func clearPendingChatState(for context: ActiveSendContext) {
switch context {
case .draftChat:
case let .draftChat(identity):
guard draftKind == .chat, draftIdentity == identity else { return }
pendingDraftChatState = nil
case let .chat(chatID):
pendingChatStates[chatID] = nil
@@ -193,6 +193,7 @@ struct SybilWorkspaceView: View {
messages: viewModel.displayedMessages,
isLoading: viewModel.isLoadingSelection,
isSending: viewModel.isSendingVisibleChat,
streamingAssistantPresentation: viewModel.displayedStreamingAssistantPresentation,
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0,
bottomPinRequestID: viewModel.chatBottomPinRequestID
@@ -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)