This commit is contained in:
@@ -5,6 +5,7 @@ struct SybilChatTranscriptView: View {
|
|||||||
var messages: [Message]
|
var messages: [Message]
|
||||||
var isLoading: Bool
|
var isLoading: Bool
|
||||||
var isSending: Bool
|
var isSending: Bool
|
||||||
|
var streamingAssistantPresentation: StreamingAssistantPresentation?
|
||||||
var topContentInset: CGFloat = 0
|
var topContentInset: CGFloat = 0
|
||||||
var bottomContentInset: CGFloat = 0
|
var bottomContentInset: CGFloat = 0
|
||||||
var bottomPinRequestID: Int = 0
|
var bottomPinRequestID: Int = 0
|
||||||
@@ -41,7 +42,11 @@ struct SybilChatTranscriptView: View {
|
|||||||
ForEach(renderItems) { item in
|
ForEach(renderItems) { item in
|
||||||
switch item {
|
switch item {
|
||||||
case let .message(message):
|
case let .message(message):
|
||||||
MessageBubble(message: message, isSending: isSending)
|
MessageBubble(
|
||||||
|
message: message,
|
||||||
|
isSending: isSending,
|
||||||
|
streamingAssistantPresentation: streamingAssistantPresentation
|
||||||
|
)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
case let .toolGroup(id, messages):
|
case let .toolGroup(id, messages):
|
||||||
ToolCallStackView(
|
ToolCallStackView(
|
||||||
@@ -140,6 +145,7 @@ func buildTranscriptRenderItems(from messages: [Message]) -> [TranscriptRenderIt
|
|||||||
private struct MessageBubble: View {
|
private struct MessageBubble: View {
|
||||||
var message: Message
|
var message: Message
|
||||||
var isSending: Bool
|
var isSending: Bool
|
||||||
|
var streamingAssistantPresentation: StreamingAssistantPresentation?
|
||||||
|
|
||||||
private var toolCallMetadata: ToolCallMetadata? {
|
private var toolCallMetadata: ToolCallMetadata? {
|
||||||
message.toolCallMetadata
|
message.toolCallMetadata
|
||||||
@@ -149,10 +155,13 @@ private struct MessageBubble: View {
|
|||||||
message.role == .user
|
message.role == .user
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isPendingAssistant: Bool {
|
private var isStreamingAssistant: Bool {
|
||||||
message.id.hasPrefix("temp-assistant-") &&
|
message.role == .assistant && message.id.hasPrefix("temp-assistant-")
|
||||||
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 {
|
var body: some View {
|
||||||
@@ -174,16 +183,12 @@ private struct MessageBubble: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if isPendingAssistant {
|
if isStreamingAssistant {
|
||||||
HStack(spacing: 8) {
|
StreamingAssistantContentView(
|
||||||
ProgressView()
|
content: message.content,
|
||||||
.controlSize(.small)
|
isSending: isSending,
|
||||||
.tint(SybilTheme.primary)
|
presentation: matchingStreamingAssistantPresentation
|
||||||
Text("Thinking…")
|
)
|
||||||
.font(.sybil(.footnote))
|
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
|
||||||
}
|
|
||||||
.padding(.vertical, 2)
|
|
||||||
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
Markdown(message.content)
|
Markdown(message.content)
|
||||||
.tint(SybilTheme.primary)
|
.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 ToolCallStackView: View {
|
||||||
private struct CardLayout {
|
private struct CardLayout {
|
||||||
var x: CGFloat
|
var x: CGFloat
|
||||||
|
|||||||
@@ -62,7 +62,11 @@ struct SybilQuickQuestionView: View {
|
|||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
if hasAnswerContent {
|
if hasAnswerContent {
|
||||||
ForEach(viewModel.quickQuestionMessages) { message in
|
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 {
|
if let error = viewModel.quickQuestionError {
|
||||||
@@ -263,11 +267,11 @@ private struct QuickQuestionPickerPill: View {
|
|||||||
private struct QuickQuestionMessageView: View {
|
private struct QuickQuestionMessageView: View {
|
||||||
var message: Message
|
var message: Message
|
||||||
var isSending: Bool
|
var isSending: Bool
|
||||||
|
var streamingAssistantPresentation: StreamingAssistantPresentation?
|
||||||
|
|
||||||
private var isPendingAssistant: Bool {
|
private var matchingStreamingAssistantPresentation: StreamingAssistantPresentation? {
|
||||||
message.id.hasPrefix("temp-assistant-quick-") &&
|
guard streamingAssistantPresentation?.messageID == message.id else { return nil }
|
||||||
isSending &&
|
return streamingAssistantPresentation
|
||||||
message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -276,15 +280,12 @@ private struct QuickQuestionMessageView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
} else if isPendingAssistant {
|
} else if message.id.hasPrefix("temp-assistant-quick-") {
|
||||||
HStack(spacing: 8) {
|
StreamingAssistantContentView(
|
||||||
ProgressView()
|
content: message.content,
|
||||||
.controlSize(.small)
|
isSending: isSending,
|
||||||
.tint(SybilTheme.primary)
|
presentation: matchingStreamingAssistantPresentation
|
||||||
Text("Thinking...")
|
)
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
|
||||||
}
|
|
||||||
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
Markdown(message.content)
|
Markdown(message.content)
|
||||||
.font(.body)
|
.font(.body)
|
||||||
|
|||||||
@@ -40,9 +40,72 @@ struct SidebarItem: Identifiable, Hashable {
|
|||||||
var isRunning: Bool
|
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 {
|
private struct PendingChatState {
|
||||||
var chatID: String?
|
var chatID: String?
|
||||||
var messages: [Message]
|
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 {
|
private enum ActiveSendContext: Hashable {
|
||||||
@@ -125,6 +188,7 @@ final class SybilViewModel {
|
|||||||
var quickQuestionSubmittedModel: String?
|
var quickQuestionSubmittedModel: String?
|
||||||
var isQuickQuestionSending = false
|
var isQuickQuestionSending = false
|
||||||
var isConvertingQuickQuestion = false
|
var isConvertingQuickQuestion = false
|
||||||
|
private var quickQuestionStreamingAssistantState = StreamingAssistantState()
|
||||||
|
|
||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
private var hasBootstrapped = false
|
private var hasBootstrapped = false
|
||||||
@@ -360,6 +424,30 @@ final class SybilViewModel {
|
|||||||
return canonical
|
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? {
|
var displayedSearch: SearchDetail? {
|
||||||
if case let .search(searchID) = selectedItem,
|
if case let .search(searchID) = selectedItem,
|
||||||
let activeSearch = activeSearchDetails[searchID] {
|
let activeSearch = activeSearchDetails[searchID] {
|
||||||
@@ -620,6 +708,7 @@ final class SybilViewModel {
|
|||||||
quickQuestionTask = nil
|
quickQuestionTask = nil
|
||||||
quickQuestionRunID = nil
|
quickQuestionRunID = nil
|
||||||
isQuickQuestionSending = false
|
isQuickQuestionSending = false
|
||||||
|
quickQuestionStreamingAssistantState = StreamingAssistantState()
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
@@ -1140,6 +1229,7 @@ final class SybilViewModel {
|
|||||||
quickQuestionSubmittedPrompt = prompt
|
quickQuestionSubmittedPrompt = prompt
|
||||||
quickQuestionSubmittedProvider = provider
|
quickQuestionSubmittedProvider = provider
|
||||||
quickQuestionSubmittedModel = model
|
quickQuestionSubmittedModel = model
|
||||||
|
quickQuestionStreamingAssistantState = StreamingAssistantState()
|
||||||
quickQuestionMessages = [
|
quickQuestionMessages = [
|
||||||
Message(
|
Message(
|
||||||
id: "temp-assistant-quick-\(UUID().uuidString)",
|
id: "temp-assistant-quick-\(UUID().uuidString)",
|
||||||
@@ -1184,6 +1274,8 @@ final class SybilViewModel {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let update = quickQuestionStreamingAssistantState.receiveError()
|
||||||
|
applyQuickQuestionAssistantContentUpdate(update)
|
||||||
quickQuestionError = normalizeAPIError(error)
|
quickQuestionError = normalizeAPIError(error)
|
||||||
SybilLog.error(SybilLog.ui, "Quick question failed", error: error)
|
SybilLog.error(SybilLog.ui, "Quick question failed", error: error)
|
||||||
}
|
}
|
||||||
@@ -1198,17 +1290,16 @@ final class SybilViewModel {
|
|||||||
upsertQuickQuestionToolCallMessage(payload)
|
upsertQuickQuestionToolCallMessage(payload)
|
||||||
|
|
||||||
case let .delta(payload):
|
case let .delta(payload):
|
||||||
guard !payload.text.isEmpty else { return }
|
let update = quickQuestionStreamingAssistantState.receiveDelta(payload.text)
|
||||||
mutateQuickQuestionAssistantMessage { existing in
|
applyQuickQuestionAssistantContentUpdate(update)
|
||||||
existing + payload.text
|
|
||||||
}
|
|
||||||
|
|
||||||
case let .done(payload):
|
case let .done(payload):
|
||||||
mutateQuickQuestionAssistantMessage { _ in
|
let update = quickQuestionStreamingAssistantState.receiveDone(payload.text)
|
||||||
payload.text
|
applyQuickQuestionAssistantContentUpdate(update)
|
||||||
}
|
|
||||||
|
|
||||||
case let .error(payload):
|
case let .error(payload):
|
||||||
|
let update = quickQuestionStreamingAssistantState.receiveError()
|
||||||
|
applyQuickQuestionAssistantContentUpdate(update)
|
||||||
await streamStatus.setError(payload.message)
|
await streamStatus.setError(payload.message)
|
||||||
|
|
||||||
case .ignored:
|
case .ignored:
|
||||||
@@ -1607,21 +1698,28 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
serverActiveChatIDs.remove(chatID)
|
serverActiveChatIDs.remove(chatID)
|
||||||
pendingChatStates[chatID] = nil
|
|
||||||
await refreshCollections(preferredSelection: selectedItem, refreshSelection: false, attachVisibleActiveRun: false)
|
await refreshCollections(preferredSelection: selectedItem, refreshSelection: false, attachVisibleActiveRun: false)
|
||||||
|
|
||||||
if selectedItem == selection, draftKind == nil {
|
if selectedItem == selection, draftKind == nil {
|
||||||
selectedChat = try await client.getChat(chatID: chatID)
|
let refreshedChat = try await client.getChat(chatID: chatID)
|
||||||
selectedSearch = nil
|
if selectedItem == selection, draftKind == nil {
|
||||||
|
selectedChat = refreshedChat
|
||||||
|
selectedSearch = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
pendingChatStates[chatID] = nil
|
||||||
} catch {
|
} catch {
|
||||||
serverActiveChatIDs.remove(chatID)
|
serverActiveChatIDs.remove(chatID)
|
||||||
pendingChatStates[chatID] = nil
|
defer {
|
||||||
|
pendingChatStates[chatID] = nil
|
||||||
|
}
|
||||||
|
|
||||||
if isCancellation(error) {
|
if isCancellation(error) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyPendingAssistantError(chatID: chatID)
|
||||||
|
|
||||||
if isActiveStreamNotFound(error) {
|
if isActiveStreamNotFound(error) {
|
||||||
SybilLog.info(SybilLog.app, "Active chat stream \(chatID) no longer exists")
|
SybilLog.info(SybilLog.app, "Active chat stream \(chatID) no longer exists")
|
||||||
} else if shouldSuppressInactiveTransportError(error) {
|
} else if shouldSuppressInactiveTransportError(error) {
|
||||||
@@ -1635,8 +1733,11 @@ final class SybilViewModel {
|
|||||||
|
|
||||||
if selectedItem == selection, draftKind == nil {
|
if selectedItem == selection, draftKind == nil {
|
||||||
do {
|
do {
|
||||||
selectedChat = try await client().getChat(chatID: chatID)
|
let refreshedChat = try await client().getChat(chatID: chatID)
|
||||||
selectedSearch = nil
|
if selectedItem == selection, draftKind == nil {
|
||||||
|
selectedChat = refreshedChat
|
||||||
|
selectedSearch = nil
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
SybilLog.warning(SybilLog.app, "Chat refresh after attach failure failed: \(SybilLog.describe(error))")
|
SybilLog.warning(SybilLog.app, "Chat refresh after attach failure failed: \(SybilLog.describe(error))")
|
||||||
}
|
}
|
||||||
@@ -1856,6 +1957,13 @@ final class SybilViewModel {
|
|||||||
} else {
|
} else {
|
||||||
pendingDraftChatState = PendingChatState(chatID: nil, messages: optimisticMessages)
|
pendingDraftChatState = PendingChatState(chatID: nil, messages: optimisticMessages)
|
||||||
}
|
}
|
||||||
|
defer {
|
||||||
|
if let resolvedChatID = chatID {
|
||||||
|
pendingChatStates[resolvedChatID] = nil
|
||||||
|
} else {
|
||||||
|
clearPendingChatState(for: sendContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
requestChatBottomPin()
|
requestChatBottomPin()
|
||||||
|
|
||||||
if chatID == nil {
|
if chatID == nil {
|
||||||
@@ -2045,17 +2153,13 @@ final class SybilViewModel {
|
|||||||
upsertPendingToolCallMessage(payload, chatID: chatID)
|
upsertPendingToolCallMessage(payload, chatID: chatID)
|
||||||
|
|
||||||
case let .delta(payload):
|
case let .delta(payload):
|
||||||
guard !payload.text.isEmpty else { return }
|
applyPendingAssistantDelta(payload.text, chatID: chatID)
|
||||||
mutatePendingAssistantMessage(chatID: chatID) { existing in
|
|
||||||
existing + payload.text
|
|
||||||
}
|
|
||||||
|
|
||||||
case let .done(payload):
|
case let .done(payload):
|
||||||
mutatePendingAssistantMessage(chatID: chatID) { _ in
|
finalizePendingAssistant(payload.text, chatID: chatID)
|
||||||
payload.text
|
|
||||||
}
|
|
||||||
|
|
||||||
case let .error(payload):
|
case let .error(payload):
|
||||||
|
applyPendingAssistantError(chatID: chatID)
|
||||||
await streamStatus.setError(payload.message)
|
await streamStatus.setError(payload.message)
|
||||||
|
|
||||||
case .ignored:
|
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 {
|
guard var pending = pendingChatStates[chatID], !pending.messages.isEmpty else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let index = pending.messages.indices.last { pending.messages[$0].id.hasPrefix("temp-assistant-") }
|
let update = pending.streamingAssistantState.receiveDelta(text)
|
||||||
guard let index else {
|
applyStreamingAssistantContentUpdate(
|
||||||
return
|
update,
|
||||||
}
|
to: &pending.messages,
|
||||||
|
assistantMessagePrefix: "temp-assistant-"
|
||||||
var message = pending.messages[index]
|
)
|
||||||
message.content = transform(message.content)
|
|
||||||
pending.messages[index] = message
|
|
||||||
pendingChatStates[chatID] = pending
|
pendingChatStates[chatID] = pending
|
||||||
}
|
}
|
||||||
|
|
||||||
private func mutateQuickQuestionAssistantMessage(_ transform: (String) -> String) {
|
private func finalizePendingAssistant(_ text: String, chatID: String) {
|
||||||
let index = quickQuestionMessages.indices.last { quickQuestionMessages[$0].id.hasPrefix("temp-assistant-quick-") }
|
guard var pending = pendingChatStates[chatID], !pending.messages.isEmpty else {
|
||||||
guard let index else {
|
|
||||||
return
|
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) {
|
private func upsertPendingToolCallMessage(_ payload: CompletionStreamToolCall, chatID: String) {
|
||||||
@@ -2263,6 +2406,16 @@ final class SybilViewModel {
|
|||||||
return
|
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)" }) {
|
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)
|
pending.messages[existingIndex] = toolCallMessage(for: payload, id: pending.messages[existingIndex].id)
|
||||||
pendingChatStates[chatID] = pending
|
pendingChatStates[chatID] = pending
|
||||||
@@ -2281,6 +2434,12 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func upsertQuickQuestionToolCallMessage(_ payload: CompletionStreamToolCall) {
|
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)" }) {
|
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)
|
quickQuestionMessages[existingIndex] = toolCallMessage(for: payload, id: quickQuestionMessages[existingIndex].id)
|
||||||
return
|
return
|
||||||
@@ -2430,7 +2589,8 @@ final class SybilViewModel {
|
|||||||
|
|
||||||
private func clearPendingChatState(for context: ActiveSendContext) {
|
private func clearPendingChatState(for context: ActiveSendContext) {
|
||||||
switch context {
|
switch context {
|
||||||
case .draftChat:
|
case let .draftChat(identity):
|
||||||
|
guard draftKind == .chat, draftIdentity == identity else { return }
|
||||||
pendingDraftChatState = nil
|
pendingDraftChatState = nil
|
||||||
case let .chat(chatID):
|
case let .chat(chatID):
|
||||||
pendingChatStates[chatID] = nil
|
pendingChatStates[chatID] = nil
|
||||||
|
|||||||
@@ -193,6 +193,7 @@ struct SybilWorkspaceView: View {
|
|||||||
messages: viewModel.displayedMessages,
|
messages: viewModel.displayedMessages,
|
||||||
isLoading: viewModel.isLoadingSelection,
|
isLoading: viewModel.isLoadingSelection,
|
||||||
isSending: viewModel.isSendingVisibleChat,
|
isSending: viewModel.isSendingVisibleChat,
|
||||||
|
streamingAssistantPresentation: viewModel.displayedStreamingAssistantPresentation,
|
||||||
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
|
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
|
||||||
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0,
|
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0,
|
||||||
bottomPinRequestID: viewModel.chatBottomPinRequestID
|
bottomPinRequestID: viewModel.chatBottomPinRequestID
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
private var lastCreateChatCall: ChatCreateCallSnapshot?
|
private var lastCreateChatCall: ChatCreateCallSnapshot?
|
||||||
private var lastQuickQuestionStreamBody: QuickQuestionStreamRequest?
|
private var lastQuickQuestionStreamBody: QuickQuestionStreamRequest?
|
||||||
private var completionStreamEvents: [CompletionStreamEvent]?
|
private var completionStreamEvents: [CompletionStreamEvent]?
|
||||||
|
private var completionStreamPostEventNetworkErrorMessage: String?
|
||||||
private var listChatsDelayNanoseconds: UInt64 = 0
|
private var listChatsDelayNanoseconds: UInt64 = 0
|
||||||
private var listSearchesDelayNanoseconds: UInt64 = 0
|
private var listSearchesDelayNanoseconds: UInt64 = 0
|
||||||
private var getChatDelayNanoseconds: UInt64 = 0
|
private var getChatDelayNanoseconds: UInt64 = 0
|
||||||
@@ -61,6 +62,7 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
private var completionStreamDelayNanoseconds: UInt64 = 0
|
private var completionStreamDelayNanoseconds: UInt64 = 0
|
||||||
private var completionAttachEvents: [String: [CompletionStreamEvent]] = [:]
|
private var completionAttachEvents: [String: [CompletionStreamEvent]] = [:]
|
||||||
private var completionAttachDelayNanoseconds: UInt64 = 0
|
private var completionAttachDelayNanoseconds: UInt64 = 0
|
||||||
|
private var chatDetailResponseSequences: [String: [ChatDetail]] = [:]
|
||||||
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 searchAttachEvents: [String: [SearchStreamEvent]] = [:]
|
||||||
@@ -113,6 +115,10 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
completionStreamDelayNanoseconds = delayNanoseconds
|
completionStreamDelayNanoseconds = delayNanoseconds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setCompletionStreamPostEventNetworkError(_ message: String) {
|
||||||
|
completionStreamPostEventNetworkErrorMessage = message
|
||||||
|
}
|
||||||
|
|
||||||
func setCompletionStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) {
|
func setCompletionStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) {
|
||||||
completionStreamNetworkErrorMessage = message
|
completionStreamNetworkErrorMessage = message
|
||||||
completionStreamDelayNanoseconds = delayNanoseconds
|
completionStreamDelayNanoseconds = delayNanoseconds
|
||||||
@@ -127,6 +133,10 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
getChatDelayNanoseconds = delayNanoseconds
|
getChatDelayNanoseconds = delayNanoseconds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setChatDetailResponses(chatID: String, responses: [ChatDetail]) {
|
||||||
|
chatDetailResponseSequences[chatID] = responses
|
||||||
|
}
|
||||||
|
|
||||||
func setGetSearchDelay(_ delayNanoseconds: UInt64) {
|
func setGetSearchDelay(_ delayNanoseconds: UInt64) {
|
||||||
getSearchDelayNanoseconds = delayNanoseconds
|
getSearchDelayNanoseconds = delayNanoseconds
|
||||||
}
|
}
|
||||||
@@ -199,6 +209,11 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
if getChatDelayNanoseconds > 0 {
|
if getChatDelayNanoseconds > 0 {
|
||||||
try await Task.sleep(nanoseconds: getChatDelayNanoseconds)
|
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 {
|
guard let detail = chatDetails[chatID] else {
|
||||||
throw UnexpectedClientCall()
|
throw UnexpectedClientCall()
|
||||||
}
|
}
|
||||||
@@ -319,6 +334,9 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
for event in completionStreamEvents {
|
for event in completionStreamEvents {
|
||||||
await onEvent(event)
|
await onEvent(event)
|
||||||
}
|
}
|
||||||
|
if let completionStreamPostEventNetworkErrorMessage {
|
||||||
|
throw APIError.networkError(message: completionStreamPostEventNetworkErrorMessage)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
throw UnexpectedClientCall()
|
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 {
|
@Test func chatForkMetadataDecodesBackwardCompatiblyAndSurvivesWorkspaceConversions() throws {
|
||||||
let decoder = JSONDecoder()
|
let decoder = JSONDecoder()
|
||||||
let legacySummary = try decoder.decode(
|
let legacySummary = try decoder.decode(
|
||||||
@@ -543,6 +580,63 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
|||||||
#expect(toolMessage.id == "tool-a")
|
#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
|
@MainActor
|
||||||
@Test func normalizedAPIBaseURLPreservesExplicitAPIPath() async throws {
|
@Test func normalizedAPIBaseURLPreservesExplicitAPIPath() async throws {
|
||||||
let defaults = UserDefaults(suiteName: #function)!
|
let defaults = UserDefaults(suiteName: #function)!
|
||||||
@@ -848,6 +942,29 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
|||||||
#expect(!viewModel.isLoadingSelection)
|
#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
|
@MainActor
|
||||||
@Test func newDraftChatDoesNotShowTypingStateFromPreviousSend() async throws {
|
@Test func newDraftChatDoesNotShowTypingStateFromPreviousSend() async throws {
|
||||||
let date = Date(timeIntervalSince1970: 1_700_000_240)
|
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)
|
#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
|
@MainActor
|
||||||
@Test func quickQuestionConvertCreatesSeededChat() async throws {
|
@Test func quickQuestionConvertCreatesSeededChat() async throws {
|
||||||
let date = Date(timeIntervalSince1970: 1_700_000_250)
|
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")
|
#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
|
@MainActor
|
||||||
@Test func activeRunOnDifferentChatDoesNotDisableComposer() async throws {
|
@Test func activeRunOnDifferentChatDoesNotDisableComposer() async throws {
|
||||||
let date = Date(timeIntervalSince1970: 1_700_000_270)
|
let date = Date(timeIntervalSince1970: 1_700_000_270)
|
||||||
|
|||||||
+51
-56
@@ -81,6 +81,16 @@ import {
|
|||||||
type SidebarSelection,
|
type SidebarSelection,
|
||||||
} from "@/lib/sidebar-selection";
|
} from "@/lib/sidebar-selection";
|
||||||
import { buildQuickQuestionRequest } from "@/lib/quick-question";
|
import { buildQuickQuestionRequest } from "@/lib/quick-question";
|
||||||
|
import {
|
||||||
|
appendStreamingDelta,
|
||||||
|
clearStreamingAssistantTrace,
|
||||||
|
createStreamingAssistantMetadata,
|
||||||
|
createStreamingAttemptState,
|
||||||
|
finalizeStreamingAssistant,
|
||||||
|
promoteStreamingAssistantTrace,
|
||||||
|
setStreamingAssistantSegment,
|
||||||
|
transitionStreamingToolCall,
|
||||||
|
} from "@/lib/chat-stream-presentation";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type DraftSelectionKind = "chat" | "search";
|
type DraftSelectionKind = "chat" | "search";
|
||||||
@@ -2486,7 +2496,7 @@ export default function App() {
|
|||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: "",
|
content: "",
|
||||||
name: null,
|
name: null,
|
||||||
metadata: null,
|
metadata: createStreamingAssistantMetadata(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let chatId = draftKind === "chat" ? null : selectedItem?.kind === "chat" ? selectedItem.id : null;
|
let chatId = draftKind === "chat" ? null : selectedItem?.kind === "chat" ? selectedItem.id : null;
|
||||||
@@ -2591,7 +2601,7 @@ export default function App() {
|
|||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
let streamErrorMessage: string | null = null;
|
let streamErrorMessage: string | null = null;
|
||||||
let replayedAssistantText = "";
|
let streamingAttempt = createStreamingAttemptState();
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
chatStreamAbortRefs.current.set(chatId, abortController);
|
chatStreamAbortRefs.current.set(chatId, abortController);
|
||||||
|
|
||||||
@@ -2609,45 +2619,39 @@ export default function App() {
|
|||||||
if (payload.chatId !== chatId) return;
|
if (payload.chatId !== chatId) return;
|
||||||
},
|
},
|
||||||
onToolCall: (payload) => {
|
onToolCall: (payload) => {
|
||||||
|
const transition = transitionStreamingToolCall(streamingAttempt, payload);
|
||||||
|
streamingAttempt = transition.attempt;
|
||||||
setPendingChatStates((current) => {
|
setPendingChatStates((current) => {
|
||||||
const pendingState = current[chatId];
|
const pendingState = current[chatId];
|
||||||
if (!pendingState) return current;
|
if (!pendingState) return current;
|
||||||
|
const messages = transition.startedNewToolCall
|
||||||
|
? promoteStreamingAssistantTrace(pendingState.messages, "temp-assistant-", transition.traceText ?? "")
|
||||||
|
: pendingState.messages;
|
||||||
return {
|
return {
|
||||||
...current,
|
...current,
|
||||||
[chatId]: {
|
[chatId]: {
|
||||||
messages: upsertOptimisticToolMessage(pendingState.messages, payload, "temp-assistant-"),
|
messages: upsertOptimisticToolMessage(messages, payload, "temp-assistant-"),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onDelta: (payload) => {
|
onDelta: (payload) => {
|
||||||
if (!payload.text) return;
|
if (!payload.text) return;
|
||||||
replayedAssistantText += payload.text;
|
streamingAttempt = appendStreamingDelta(streamingAttempt, payload.text);
|
||||||
|
const segmentText = streamingAttempt.segmentText;
|
||||||
setPendingChatStates((current) => {
|
setPendingChatStates((current) => {
|
||||||
const pendingState = current[chatId];
|
const pendingState = current[chatId];
|
||||||
if (!pendingState) return current;
|
if (!pendingState) return current;
|
||||||
let updated = false;
|
const nextMessages = setStreamingAssistantSegment(pendingState.messages, "temp-assistant-", segmentText);
|
||||||
const nextMessages = pendingState.messages.map((message, index, all) => {
|
return nextMessages === pendingState.messages ? current : { ...current, [chatId]: { messages: nextMessages } };
|
||||||
const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-");
|
|
||||||
if (!isTarget) return message;
|
|
||||||
updated = true;
|
|
||||||
return { ...message, content: replayedAssistantText };
|
|
||||||
});
|
|
||||||
return updated ? { ...current, [chatId]: { messages: nextMessages } } : current;
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onDone: (payload) => {
|
onDone: (payload) => {
|
||||||
setPendingChatStates((current) => {
|
setPendingChatStates((current) => {
|
||||||
const pendingState = current[chatId];
|
const pendingState = current[chatId];
|
||||||
if (!pendingState) return current;
|
if (!pendingState) return current;
|
||||||
let updated = false;
|
const nextMessages = finalizeStreamingAssistant(pendingState.messages, "temp-assistant-", payload.text);
|
||||||
const nextMessages = pendingState.messages.map((message, index, all) => {
|
return nextMessages === pendingState.messages ? current : { ...current, [chatId]: { messages: nextMessages } };
|
||||||
const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-");
|
|
||||||
if (!isTarget) return message;
|
|
||||||
updated = true;
|
|
||||||
return { ...message, content: payload.text };
|
|
||||||
});
|
|
||||||
return updated ? { ...current, [chatId]: { messages: nextMessages } } : current;
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (payload) => {
|
onError: (payload) => {
|
||||||
@@ -2896,6 +2900,7 @@ export default function App() {
|
|||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
chatStreamAbortRefs.current.set(chatId, abortController);
|
chatStreamAbortRefs.current.set(chatId, abortController);
|
||||||
let streamErrorMessage: string | null = null;
|
let streamErrorMessage: string | null = null;
|
||||||
|
let streamingAttempt = createStreamingAttemptState();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const baseChat = await getChat(chatId);
|
const baseChat = await getChat(chatId);
|
||||||
@@ -2912,7 +2917,7 @@ export default function App() {
|
|||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: "",
|
content: "",
|
||||||
name: null,
|
name: null,
|
||||||
metadata: null,
|
metadata: createStreamingAssistantMetadata(),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -2922,44 +2927,39 @@ export default function App() {
|
|||||||
chatId,
|
chatId,
|
||||||
{
|
{
|
||||||
onToolCall: (payload) => {
|
onToolCall: (payload) => {
|
||||||
|
const transition = transitionStreamingToolCall(streamingAttempt, payload);
|
||||||
|
streamingAttempt = transition.attempt;
|
||||||
setPendingChatStates((current) => {
|
setPendingChatStates((current) => {
|
||||||
const pendingState = current[chatId];
|
const pendingState = current[chatId];
|
||||||
if (!pendingState) return current;
|
if (!pendingState) return current;
|
||||||
|
const messages = transition.startedNewToolCall
|
||||||
|
? promoteStreamingAssistantTrace(pendingState.messages, "temp-assistant-", transition.traceText ?? "")
|
||||||
|
: pendingState.messages;
|
||||||
return {
|
return {
|
||||||
...current,
|
...current,
|
||||||
[chatId]: {
|
[chatId]: {
|
||||||
messages: upsertOptimisticToolMessage(pendingState.messages, payload, "temp-assistant-"),
|
messages: upsertOptimisticToolMessage(messages, payload, "temp-assistant-"),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onDelta: (payload) => {
|
onDelta: (payload) => {
|
||||||
if (!payload.text) return;
|
if (!payload.text) return;
|
||||||
|
streamingAttempt = appendStreamingDelta(streamingAttempt, payload.text);
|
||||||
|
const segmentText = streamingAttempt.segmentText;
|
||||||
setPendingChatStates((current) => {
|
setPendingChatStates((current) => {
|
||||||
const pendingState = current[chatId];
|
const pendingState = current[chatId];
|
||||||
if (!pendingState) return current;
|
if (!pendingState) return current;
|
||||||
let updated = false;
|
const nextMessages = setStreamingAssistantSegment(pendingState.messages, "temp-assistant-", segmentText);
|
||||||
const nextMessages = pendingState.messages.map((message, index, all) => {
|
return nextMessages === pendingState.messages ? current : { ...current, [chatId]: { messages: nextMessages } };
|
||||||
const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-");
|
|
||||||
if (!isTarget) return message;
|
|
||||||
updated = true;
|
|
||||||
return { ...message, content: message.content + payload.text };
|
|
||||||
});
|
|
||||||
return updated ? { ...current, [chatId]: { messages: nextMessages } } : current;
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onDone: (payload) => {
|
onDone: (payload) => {
|
||||||
setPendingChatStates((current) => {
|
setPendingChatStates((current) => {
|
||||||
const pendingState = current[chatId];
|
const pendingState = current[chatId];
|
||||||
if (!pendingState) return current;
|
if (!pendingState) return current;
|
||||||
let updated = false;
|
const nextMessages = finalizeStreamingAssistant(pendingState.messages, "temp-assistant-", payload.text);
|
||||||
const nextMessages = pendingState.messages.map((message, index, all) => {
|
return nextMessages === pendingState.messages ? current : { ...current, [chatId]: { messages: nextMessages } };
|
||||||
const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-");
|
|
||||||
if (!isTarget) return message;
|
|
||||||
updated = true;
|
|
||||||
return { ...message, content: payload.text };
|
|
||||||
});
|
|
||||||
return updated ? { ...current, [chatId]: { messages: nextMessages } } : current;
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (payload) => {
|
onError: (payload) => {
|
||||||
@@ -3211,7 +3211,7 @@ export default function App() {
|
|||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: "",
|
content: "",
|
||||||
name: null,
|
name: null,
|
||||||
metadata: null,
|
metadata: createStreamingAssistantMetadata(),
|
||||||
};
|
};
|
||||||
|
|
||||||
quickQuestionAbortRef.current?.abort();
|
quickQuestionAbortRef.current?.abort();
|
||||||
@@ -3225,6 +3225,7 @@ export default function App() {
|
|||||||
setIsQuickQuestionSending(true);
|
setIsQuickQuestionSending(true);
|
||||||
|
|
||||||
let streamErrorMessage: string | null = null;
|
let streamErrorMessage: string | null = null;
|
||||||
|
let streamingAttempt = createStreamingAttemptState();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await runQuickQuestionStream(
|
await runQuickQuestionStream(
|
||||||
@@ -3235,33 +3236,26 @@ export default function App() {
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
onToolCall: (payload) => {
|
onToolCall: (payload) => {
|
||||||
|
const transition = transitionStreamingToolCall(streamingAttempt, payload);
|
||||||
|
streamingAttempt = transition.attempt;
|
||||||
setQuickQuestionMessages((current) => {
|
setQuickQuestionMessages((current) => {
|
||||||
return upsertOptimisticToolMessage(current, payload, "temp-assistant-quick-");
|
const messages = transition.startedNewToolCall
|
||||||
|
? promoteStreamingAssistantTrace(current, "temp-assistant-quick-", transition.traceText ?? "")
|
||||||
|
: current;
|
||||||
|
return upsertOptimisticToolMessage(messages, payload, "temp-assistant-quick-");
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onDelta: (payload) => {
|
onDelta: (payload) => {
|
||||||
if (!payload.text) return;
|
if (!payload.text) return;
|
||||||
|
streamingAttempt = appendStreamingDelta(streamingAttempt, payload.text);
|
||||||
|
const segmentText = streamingAttempt.segmentText;
|
||||||
setQuickQuestionMessages((current) => {
|
setQuickQuestionMessages((current) => {
|
||||||
let updated = false;
|
return setStreamingAssistantSegment(current, "temp-assistant-quick-", segmentText);
|
||||||
const nextMessages = current.map((message, index, all) => {
|
|
||||||
const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-quick-");
|
|
||||||
if (!isTarget) return message;
|
|
||||||
updated = true;
|
|
||||||
return { ...message, content: message.content + payload.text };
|
|
||||||
});
|
|
||||||
return updated ? nextMessages : current;
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onDone: (payload) => {
|
onDone: (payload) => {
|
||||||
setQuickQuestionMessages((current) => {
|
setQuickQuestionMessages((current) => {
|
||||||
let updated = false;
|
return finalizeStreamingAssistant(current, "temp-assistant-quick-", payload.text);
|
||||||
const nextMessages = current.map((message, index, all) => {
|
|
||||||
const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-quick-");
|
|
||||||
if (!isTarget) return message;
|
|
||||||
updated = true;
|
|
||||||
return { ...message, content: payload.text };
|
|
||||||
});
|
|
||||||
return updated ? nextMessages : current;
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (payload) => {
|
onError: (payload) => {
|
||||||
@@ -3276,6 +3270,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (abortController.signal.aborted) return;
|
if (abortController.signal.aborted) return;
|
||||||
|
setQuickQuestionMessages((current) => clearStreamingAssistantTrace(current, "temp-assistant-quick-"));
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
if (message.includes("bearer token")) {
|
if (message.includes("bearer token")) {
|
||||||
handleAuthFailure(message);
|
handleAuthFailure(message);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||||
import type { ComponentChildren, JSX } from "preact";
|
import type { ComponentChildren, JSX } from "preact";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
|
import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
|
||||||
import { getMessageAttachments, type Message } from "@/lib/api";
|
import { getMessageAttachments, type Message } from "@/lib/api";
|
||||||
|
import { asStreamingAssistantMetadata } from "@/lib/chat-stream-presentation";
|
||||||
import { MarkdownContent } from "@/components/markdown/markdown-content";
|
import { MarkdownContent } from "@/components/markdown/markdown-content";
|
||||||
import { ChevronDown, ChevronUp, Globe2, Link2, Wrench } from "lucide-preact";
|
import { ChevronDown, ChevronUp, Globe2, Link2, Wrench } from "lucide-preact";
|
||||||
|
|
||||||
@@ -396,8 +397,144 @@ function ToolCallStack({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RenderedThinkingTrace = {
|
||||||
|
text: string;
|
||||||
|
revision: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const THINKING_TRACE_EXIT_MS = 220;
|
||||||
|
|
||||||
|
export function StreamingAssistantPresentation({ message, isSending }: { message: Message; isSending: boolean }) {
|
||||||
|
const metadata = asStreamingAssistantMetadata(message.metadata);
|
||||||
|
const traceText = metadata?.ephemeralTrace?.trim() || null;
|
||||||
|
const traceRevision = metadata?.traceRevision ?? 0;
|
||||||
|
const [activeTrace, setActiveTrace] = useState<RenderedThinkingTrace | null>(() =>
|
||||||
|
traceText ? { text: traceText, revision: traceRevision } : null
|
||||||
|
);
|
||||||
|
const [outgoingTrace, setOutgoingTrace] = useState<RenderedThinkingTrace | null>(null);
|
||||||
|
const [reservedTraceHeight, setReservedTraceHeight] = useState(0);
|
||||||
|
const activeTraceRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const outgoingTraceRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const exitTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const isSameTrace =
|
||||||
|
(!activeTrace && !traceText) ||
|
||||||
|
(activeTrace?.text === traceText && activeTrace?.revision === traceRevision && Boolean(activeTrace) === Boolean(traceText));
|
||||||
|
if (isSameTrace) return;
|
||||||
|
|
||||||
|
if (exitTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(exitTimerRef.current);
|
||||||
|
exitTimerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeTrace) {
|
||||||
|
setOutgoingTrace(activeTrace);
|
||||||
|
exitTimerRef.current = window.setTimeout(() => {
|
||||||
|
setOutgoingTrace(null);
|
||||||
|
exitTimerRef.current = null;
|
||||||
|
}, THINKING_TRACE_EXIT_MS);
|
||||||
|
} else {
|
||||||
|
setOutgoingTrace(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveTrace(traceText ? { text: traceText, revision: traceRevision } : null);
|
||||||
|
}, [traceRevision, traceText]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (exitTimerRef.current !== null) window.clearTimeout(exitTimerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const traceElements = [activeTraceRef.current, outgoingTraceRef.current].filter(
|
||||||
|
(element): element is HTMLDivElement => element !== null
|
||||||
|
);
|
||||||
|
if (!traceElements.length) return;
|
||||||
|
|
||||||
|
const reserveMeasuredHeight = () => {
|
||||||
|
const measuredHeight = Math.max(...traceElements.map((element) => element.getBoundingClientRect().height));
|
||||||
|
if (measuredHeight > 0) {
|
||||||
|
setReservedTraceHeight((current) => Math.max(current, Math.ceil(measuredHeight)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
reserveMeasuredHeight();
|
||||||
|
if (typeof ResizeObserver === "undefined") return;
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(reserveMeasuredHeight);
|
||||||
|
for (const element of traceElements) observer.observe(element);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [activeTrace, outgoingTrace]);
|
||||||
|
|
||||||
|
const hasAnswer = message.content.trim().length > 0;
|
||||||
|
const showTyping = isSending && !hasAnswer && !traceText && !activeTrace && !outgoingTrace;
|
||||||
|
const phase = hasAnswer ? "answer" : traceText || activeTrace || outgoingTrace ? "trace" : "pending";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="streaming-assistant-slot"
|
||||||
|
data-streaming-assistant="true"
|
||||||
|
data-streaming-phase={phase}
|
||||||
|
style={reservedTraceHeight ? { minHeight: `${reservedTraceHeight}px` } : undefined}
|
||||||
|
>
|
||||||
|
{outgoingTrace ? (
|
||||||
|
<div
|
||||||
|
ref={outgoingTraceRef}
|
||||||
|
key={`outgoing-trace-${outgoingTrace.revision}`}
|
||||||
|
className="streaming-assistant-layer ephemeral-thinking-trace ephemeral-thinking-trace-exit"
|
||||||
|
data-thinking-trace="true"
|
||||||
|
data-thinking-trace-state="outgoing"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<p className="whitespace-pre-wrap">{outgoingTrace.text}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{activeTrace ? (
|
||||||
|
<div
|
||||||
|
ref={activeTraceRef}
|
||||||
|
key={`active-trace-${activeTrace.revision}`}
|
||||||
|
className="streaming-assistant-layer ephemeral-thinking-trace ephemeral-thinking-trace-enter"
|
||||||
|
data-thinking-trace="true"
|
||||||
|
data-thinking-trace-state="active"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<p className="whitespace-pre-wrap">{activeTrace.text}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{activeTrace && !hasAnswer ? (
|
||||||
|
<span className="sr-only" role="status" aria-live="polite">
|
||||||
|
Assistant is working
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{hasAnswer ? (
|
||||||
|
<div className="streaming-assistant-layer streaming-assistant-answer" data-streaming-answer="true">
|
||||||
|
<MarkdownContent
|
||||||
|
markdown={message.content}
|
||||||
|
openLinksInNewTab
|
||||||
|
className="leading-[1.82] text-violet-50 [&_a]:text-inherit [&_a]:underline"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{showTyping ? (
|
||||||
|
<span
|
||||||
|
className="streaming-assistant-layer inline-flex items-center gap-1"
|
||||||
|
data-streaming-pending="true"
|
||||||
|
aria-label="Assistant is typing"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms]" />
|
||||||
|
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:140ms]" />
|
||||||
|
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:280ms]" />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageContextMenu }: Props) {
|
export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageContextMenu }: Props) {
|
||||||
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-") && message.content.trim().length === 0);
|
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-"));
|
||||||
const renderItems = useMemo(() => buildMessageRenderItems(messages), [messages]);
|
const renderItems = useMemo(() => buildMessageRenderItems(messages), [messages]);
|
||||||
const toolCallMessageIDs = useMemo(() => getToolCallMessageIDs(messages), [messages]);
|
const toolCallMessageIDs = useMemo(() => getToolCallMessageIDs(messages), [messages]);
|
||||||
const seenToolCallMessageIDsRef = useRef<Set<string> | null>(null);
|
const seenToolCallMessageIDsRef = useRef<Set<string> | null>(null);
|
||||||
@@ -457,6 +594,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageCon
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isUser = message.role === "user";
|
const isUser = message.role === "user";
|
||||||
|
const streamingAssistantMetadata = asStreamingAssistantMetadata(message.metadata);
|
||||||
const isPendingAssistant = message.id.startsWith("temp-assistant-") && isSending && message.content.trim().length === 0;
|
const isPendingAssistant = message.id.startsWith("temp-assistant-") && isSending && message.content.trim().length === 0;
|
||||||
const attachments = getMessageAttachments(message.metadata);
|
const attachments = getMessageAttachments(message.metadata);
|
||||||
return (
|
return (
|
||||||
@@ -475,7 +613,9 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageCon
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{attachments.length ? <ChatAttachmentList attachments={attachments} tone={isUser ? "user" : "assistant"} /> : null}
|
{attachments.length ? <ChatAttachmentList attachments={attachments} tone={isUser ? "user" : "assistant"} /> : null}
|
||||||
{isPendingAssistant ? (
|
{streamingAssistantMetadata ? (
|
||||||
|
<StreamingAssistantPresentation message={message} isSending={isSending} />
|
||||||
|
) : isPendingAssistant ? (
|
||||||
<span className="inline-flex items-center gap-1" aria-label="Assistant is typing" role="status">
|
<span className="inline-flex items-center gap-1" aria-label="Assistant is typing" role="status">
|
||||||
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms]" />
|
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms]" />
|
||||||
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:140ms]" />
|
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:140ms]" />
|
||||||
|
|||||||
@@ -321,6 +321,97 @@ textarea {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.streaming-assistant-slot {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 1.82em;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streaming-assistant-layer {
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
min-width: 0;
|
||||||
|
transform-origin: top left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ephemeral-thinking-trace {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
color: hsl(252 35% 88% / 0.8);
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.78;
|
||||||
|
filter: saturate(0.72);
|
||||||
|
will-change: opacity, transform, filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ephemeral-thinking-trace-enter {
|
||||||
|
animation: ephemeral-thinking-trace-enter 200ms cubic-bezier(0.2, 0.78, 0.24, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ephemeral-thinking-trace-exit {
|
||||||
|
pointer-events: none;
|
||||||
|
animation: ephemeral-thinking-trace-exit 220ms cubic-bezier(0.4, 0, 0.8, 0.28) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streaming-assistant-answer {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
animation: streaming-assistant-answer-enter 180ms cubic-bezier(0.2, 0.78, 0.24, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ephemeral-thinking-trace-enter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
filter: saturate(0.72) blur(1px);
|
||||||
|
transform: translate3d(0, 0.32rem, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 0.78;
|
||||||
|
filter: saturate(0.72) blur(0);
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ephemeral-thinking-trace-exit {
|
||||||
|
from {
|
||||||
|
opacity: 0.78;
|
||||||
|
filter: saturate(0.72) blur(0);
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
filter: saturate(0.72) blur(1px);
|
||||||
|
transform: translate3d(0, -0.32rem, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes streaming-assistant-answer-enter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate3d(0, 0.18rem, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.ephemeral-thinking-trace-enter,
|
||||||
|
.ephemeral-thinking-trace-exit,
|
||||||
|
.streaming-assistant-answer {
|
||||||
|
animation: none;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ephemeral-thinking-trace-exit {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.md-content {
|
.md-content {
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import type { Message, ToolCallEvent } from "@/lib/api";
|
||||||
|
|
||||||
|
export const STREAMING_ASSISTANT_METADATA_KIND = "streaming_assistant";
|
||||||
|
|
||||||
|
export type StreamingAssistantMetadata = {
|
||||||
|
kind: typeof STREAMING_ASSISTANT_METADATA_KIND;
|
||||||
|
ephemeralTrace: string | null;
|
||||||
|
traceRevision: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StreamingAttemptState = {
|
||||||
|
segmentText: string;
|
||||||
|
initiatedToolCallIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StreamingToolCallTransition = {
|
||||||
|
attempt: StreamingAttemptState;
|
||||||
|
startedNewToolCall: boolean;
|
||||||
|
traceText: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createStreamingAssistantMetadata(): StreamingAssistantMetadata {
|
||||||
|
return {
|
||||||
|
kind: STREAMING_ASSISTANT_METADATA_KIND,
|
||||||
|
ephemeralTrace: null,
|
||||||
|
traceRevision: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asStreamingAssistantMetadata(value: unknown): StreamingAssistantMetadata | null {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
if (record.kind !== STREAMING_ASSISTANT_METADATA_KIND) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: STREAMING_ASSISTANT_METADATA_KIND,
|
||||||
|
ephemeralTrace: typeof record.ephemeralTrace === "string" ? record.ephemeralTrace : null,
|
||||||
|
traceRevision:
|
||||||
|
typeof record.traceRevision === "number" && Number.isFinite(record.traceRevision)
|
||||||
|
? Math.max(0, Math.floor(record.traceRevision))
|
||||||
|
: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStreamingAttemptState(): StreamingAttemptState {
|
||||||
|
return {
|
||||||
|
segmentText: "",
|
||||||
|
initiatedToolCallIds: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendStreamingDelta(attempt: StreamingAttemptState, delta: string): StreamingAttemptState {
|
||||||
|
if (!delta) return attempt;
|
||||||
|
return {
|
||||||
|
...attempt,
|
||||||
|
segmentText: attempt.segmentText + delta,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transitionStreamingToolCall(
|
||||||
|
attempt: StreamingAttemptState,
|
||||||
|
event: Pick<ToolCallEvent, "toolCallId" | "status">
|
||||||
|
): StreamingToolCallTransition {
|
||||||
|
if (event.status !== "initiated" || attempt.initiatedToolCallIds.includes(event.toolCallId)) {
|
||||||
|
return { attempt, startedNewToolCall: false, traceText: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const traceText = attempt.segmentText.trim();
|
||||||
|
return {
|
||||||
|
attempt: {
|
||||||
|
segmentText: "",
|
||||||
|
initiatedToolCallIds: attempt.initiatedToolCallIds.concat(event.toolCallId),
|
||||||
|
},
|
||||||
|
startedNewToolCall: true,
|
||||||
|
traceText: traceText || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStreamingAssistant(
|
||||||
|
messages: Message[],
|
||||||
|
assistantMessagePrefix: string,
|
||||||
|
update: (message: Message, metadata: StreamingAssistantMetadata) => Message
|
||||||
|
) {
|
||||||
|
let didUpdate = false;
|
||||||
|
const nextMessages = messages.map((message, index, all) => {
|
||||||
|
const isTarget = index === all.length - 1 && message.id.startsWith(assistantMessagePrefix);
|
||||||
|
if (!isTarget) return message;
|
||||||
|
didUpdate = true;
|
||||||
|
return update(message, asStreamingAssistantMetadata(message.metadata) ?? createStreamingAssistantMetadata());
|
||||||
|
});
|
||||||
|
return didUpdate ? nextMessages : messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setStreamingAssistantSegment(messages: Message[], assistantMessagePrefix: string, segmentText: string) {
|
||||||
|
return updateStreamingAssistant(messages, assistantMessagePrefix, (message, metadata) => ({
|
||||||
|
...message,
|
||||||
|
content: segmentText,
|
||||||
|
metadata: {
|
||||||
|
...metadata,
|
||||||
|
ephemeralTrace: null,
|
||||||
|
} satisfies StreamingAssistantMetadata,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function promoteStreamingAssistantTrace(messages: Message[], assistantMessagePrefix: string, traceText: string) {
|
||||||
|
const normalizedTrace = traceText.trim();
|
||||||
|
return updateStreamingAssistant(messages, assistantMessagePrefix, (message, metadata) => ({
|
||||||
|
...message,
|
||||||
|
content: "",
|
||||||
|
metadata: normalizedTrace
|
||||||
|
? ({
|
||||||
|
...metadata,
|
||||||
|
ephemeralTrace: normalizedTrace,
|
||||||
|
traceRevision: metadata.traceRevision + 1,
|
||||||
|
} satisfies StreamingAssistantMetadata)
|
||||||
|
: metadata,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function finalizeStreamingAssistant(messages: Message[], assistantMessagePrefix: string, finalText: string) {
|
||||||
|
return updateStreamingAssistant(messages, assistantMessagePrefix, (message, metadata) => ({
|
||||||
|
...message,
|
||||||
|
content: finalText,
|
||||||
|
metadata: {
|
||||||
|
...metadata,
|
||||||
|
ephemeralTrace: null,
|
||||||
|
} satisfies StreamingAssistantMetadata,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearStreamingAssistantTrace(messages: Message[], assistantMessagePrefix: string) {
|
||||||
|
return updateStreamingAssistant(messages, assistantMessagePrefix, (message, metadata) => ({
|
||||||
|
...message,
|
||||||
|
metadata: {
|
||||||
|
...metadata,
|
||||||
|
ephemeralTrace: null,
|
||||||
|
} satisfies StreamingAssistantMetadata,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import {
|
||||||
|
appendStreamingDelta,
|
||||||
|
asStreamingAssistantMetadata,
|
||||||
|
clearStreamingAssistantTrace,
|
||||||
|
createStreamingAssistantMetadata,
|
||||||
|
createStreamingAttemptState,
|
||||||
|
finalizeStreamingAssistant,
|
||||||
|
promoteStreamingAssistantTrace,
|
||||||
|
setStreamingAssistantSegment,
|
||||||
|
transitionStreamingToolCall,
|
||||||
|
} from "../src/lib/chat-stream-presentation.ts";
|
||||||
|
|
||||||
|
function assistantMessage(content = "") {
|
||||||
|
return {
|
||||||
|
id: "temp-assistant-test",
|
||||||
|
createdAt: "2026-08-30T00:00:00.000Z",
|
||||||
|
role: "assistant",
|
||||||
|
content,
|
||||||
|
name: null,
|
||||||
|
metadata: createStreamingAssistantMetadata(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function metadata(messages) {
|
||||||
|
return asStreamingAssistantMetadata(messages.at(-1)?.metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a tool boundary promotes the current segment and the next delta starts the answer in its place", () => {
|
||||||
|
let messages = [assistantMessage()];
|
||||||
|
let attempt = appendStreamingDelta(createStreamingAttemptState(), "I'll search for that.");
|
||||||
|
messages = setStreamingAssistantSegment(messages, "temp-assistant-", attempt.segmentText);
|
||||||
|
|
||||||
|
const toolTransition = transitionStreamingToolCall(attempt, {
|
||||||
|
toolCallId: "search-1",
|
||||||
|
status: "initiated",
|
||||||
|
});
|
||||||
|
attempt = toolTransition.attempt;
|
||||||
|
messages = promoteStreamingAssistantTrace(messages, "temp-assistant-", toolTransition.traceText ?? "");
|
||||||
|
|
||||||
|
assert.equal(messages.at(-1).content, "");
|
||||||
|
assert.deepEqual(metadata(messages), {
|
||||||
|
kind: "streaming_assistant",
|
||||||
|
ephemeralTrace: "I'll search for that.",
|
||||||
|
traceRevision: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
attempt = appendStreamingDelta(attempt, "Here is the answer");
|
||||||
|
messages = setStreamingAssistantSegment(messages, "temp-assistant-", attempt.segmentText);
|
||||||
|
|
||||||
|
assert.equal(messages.at(-1).content, "Here is the answer");
|
||||||
|
assert.equal(metadata(messages)?.ephemeralTrace, null);
|
||||||
|
|
||||||
|
messages = finalizeStreamingAssistant(messages, "temp-assistant-", "Here is the answer.");
|
||||||
|
assert.equal(messages.at(-1).content, "Here is the answer.");
|
||||||
|
assert.equal(metadata(messages)?.ephemeralTrace, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a later tool round replaces the previous trace and parallel initiated calls do not erase it", () => {
|
||||||
|
let messages = [assistantMessage("First trace")];
|
||||||
|
let attempt = appendStreamingDelta(createStreamingAttemptState(), "First trace");
|
||||||
|
let transition = transitionStreamingToolCall(attempt, { toolCallId: "tool-1", status: "initiated" });
|
||||||
|
attempt = transition.attempt;
|
||||||
|
messages = promoteStreamingAssistantTrace(messages, "temp-assistant-", transition.traceText ?? "");
|
||||||
|
|
||||||
|
attempt = appendStreamingDelta(attempt, "I need one more lookup.");
|
||||||
|
messages = setStreamingAssistantSegment(messages, "temp-assistant-", attempt.segmentText);
|
||||||
|
transition = transitionStreamingToolCall(attempt, { toolCallId: "tool-2", status: "initiated" });
|
||||||
|
attempt = transition.attempt;
|
||||||
|
messages = promoteStreamingAssistantTrace(messages, "temp-assistant-", transition.traceText ?? "");
|
||||||
|
|
||||||
|
assert.equal(metadata(messages)?.ephemeralTrace, "I need one more lookup.");
|
||||||
|
assert.equal(metadata(messages)?.traceRevision, 2);
|
||||||
|
|
||||||
|
transition = transitionStreamingToolCall(attempt, { toolCallId: "tool-3", status: "initiated" });
|
||||||
|
assert.equal(transition.startedNewToolCall, true);
|
||||||
|
assert.equal(transition.traceText, null);
|
||||||
|
messages = promoteStreamingAssistantTrace(messages, "temp-assistant-", transition.traceText ?? "");
|
||||||
|
|
||||||
|
assert.equal(metadata(messages)?.ephemeralTrace, "I need one more lookup.");
|
||||||
|
assert.equal(metadata(messages)?.traceRevision, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("terminal and duplicate tool events never promote or reset the in-progress segment", () => {
|
||||||
|
const initial = appendStreamingDelta(createStreamingAttemptState(), "candidate");
|
||||||
|
const completed = transitionStreamingToolCall(initial, { toolCallId: "tool-1", status: "completed" });
|
||||||
|
|
||||||
|
assert.equal(completed.startedNewToolCall, false);
|
||||||
|
assert.strictEqual(completed.attempt, initial);
|
||||||
|
|
||||||
|
const initiated = transitionStreamingToolCall(initial, { toolCallId: "tool-1", status: "initiated" });
|
||||||
|
const withNextSegment = appendStreamingDelta(initiated.attempt, "next segment");
|
||||||
|
const duplicate = transitionStreamingToolCall(withNextSegment, { toolCallId: "tool-1", status: "initiated" });
|
||||||
|
|
||||||
|
assert.equal(duplicate.startedNewToolCall, false);
|
||||||
|
assert.strictEqual(duplicate.attempt, withNextSegment);
|
||||||
|
assert.equal(duplicate.attempt.segmentText, "next segment");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a fresh per-attempt accumulator can replay and reclassify an already-seen tool round", () => {
|
||||||
|
let messages = [assistantMessage("stale partial answer")];
|
||||||
|
let replayAttempt = appendStreamingDelta(createStreamingAttemptState(), "Replayed trace");
|
||||||
|
messages = setStreamingAssistantSegment(messages, "temp-assistant-", replayAttempt.segmentText);
|
||||||
|
|
||||||
|
const replayedTool = transitionStreamingToolCall(replayAttempt, { toolCallId: "same-tool-id", status: "initiated" });
|
||||||
|
messages = promoteStreamingAssistantTrace(messages, "temp-assistant-", replayedTool.traceText ?? "");
|
||||||
|
|
||||||
|
assert.equal(replayedTool.startedNewToolCall, true);
|
||||||
|
assert.equal(messages.at(-1).content, "");
|
||||||
|
assert.equal(metadata(messages)?.ephemeralTrace, "Replayed trace");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("message helpers preserve identity when no matching temporary assistant exists", () => {
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
id: "persisted-assistant",
|
||||||
|
createdAt: "2026-08-30T00:00:00.000Z",
|
||||||
|
role: "assistant",
|
||||||
|
content: "Persisted",
|
||||||
|
name: null,
|
||||||
|
metadata: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.strictEqual(setStreamingAssistantSegment(messages, "temp-assistant-", "new"), messages);
|
||||||
|
assert.strictEqual(promoteStreamingAssistantTrace(messages, "temp-assistant-", "trace"), messages);
|
||||||
|
assert.strictEqual(finalizeStreamingAssistant(messages, "temp-assistant-", "done"), messages);
|
||||||
|
assert.strictEqual(clearStreamingAssistantTrace(messages, "temp-assistant-"), messages);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a terminal error clears the ephemeral trace without discarding a partial answer", () => {
|
||||||
|
let messages = [
|
||||||
|
{
|
||||||
|
...assistantMessage("Partial answer"),
|
||||||
|
metadata: {
|
||||||
|
...createStreamingAssistantMetadata(),
|
||||||
|
ephemeralTrace: "Still checking one source.",
|
||||||
|
traceRevision: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
messages = clearStreamingAssistantTrace(messages, "temp-assistant-");
|
||||||
|
|
||||||
|
assert.equal(messages.at(-1).content, "Partial answer");
|
||||||
|
assert.equal(metadata(messages)?.ephemeralTrace, null);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user