Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a390c7287a |
+1
-5
@@ -180,7 +180,7 @@ Behavior notes:
|
|||||||
|
|
||||||
Behavior notes:
|
Behavior notes:
|
||||||
- With no `messageId`, the server copies the complete source transcript. With an assistant response `messageId`, it copies the transcript through that response, inclusive.
|
- With no `messageId`, the server copies the complete source transcript. With an assistant response `messageId`, it copies the transcript through that response, inclusive.
|
||||||
- The source chat is unchanged. Copied messages receive new ids while retaining their role, content, name, creation time, order, and metadata except for the source request's transport-only `clientRequestId`. Attachments, tool-call metadata, and per-response `metadata.generation` are retained. For older replies with an exact call-log match, generation metadata is recovered before copying. LLM call logs, star/project memberships, and other child threads are not copied.
|
- The source chat is unchanged. Copied messages receive new ids while retaining their role, content, name, creation time, order, and metadata except for the source request's transport-only `clientRequestId`. Attachments and tool-call metadata are retained. LLM call logs, star/project memberships, and other child threads are not copied.
|
||||||
- The fork inherits the source chat's user, initiated/last-used provider and model, additional system prompt, and enabled tool settings.
|
- The fork inherits the source chat's user, initiated/last-used provider and model, additional system prompt, and enabled tool settings.
|
||||||
- A fork of a root or another fork always sets `parentChatId` to the single root chat id. Root chats keep `parentChatId: null`.
|
- A fork of a root or another fork always sets `parentChatId` to the single root chat id. Root chats keep `parentChatId: null`.
|
||||||
- A whole-chat fork starts with `Fork of <source title>` (or `Fork of Untitled chat`). A message fork starts with `Fork of '<message snippet>'`.
|
- A whole-chat fork starts with `Fork of <source title>` (or `Fork of Untitled chat`). A message fork starts with `Fork of '<message snippet>'`.
|
||||||
@@ -233,9 +233,6 @@ Behavior notes:
|
|||||||
|
|
||||||
### `GET /v1/chats/:chatId`
|
### `GET /v1/chats/:chatId`
|
||||||
- Response: `{ "chat": ChatDetail }`
|
- Response: `{ "chat": ChatDetail }`
|
||||||
- Saved ordinary assistant responses can include `metadata.generation: { "provider": "openai", "model": "gpt-4.1-mini" }`. These are the provider/model identifiers used for that completion request, captured separately from the chat's mutable last-used selection. Model aliases remain the requested alias; this does not claim an underlying provider snapshot ID.
|
|
||||||
- Newly generated responses store this metadata for both streaming and non-streaming completions. It survives reloads and forks. Inline Sybil Agent replies retain their separate `metadata.agentAside` identity.
|
|
||||||
- For older assistant messages without generation metadata, this response recovers it only when `metadata.clientRequestId` matches exactly one completed, successful call in the same chat. This read does not rewrite stored history. Missing or ambiguous records, including some older forks/imports, remain unattributed; clients must not substitute the chat's current or last-used model. The web footer displays “Model not recorded” in that case.
|
|
||||||
|
|
||||||
### `GET /v1/chats/:chatId/messages`
|
### `GET /v1/chats/:chatId/messages`
|
||||||
- Requires the same authenticated owner/admin credential as other chat routes. Responses use `Cache-Control: no-store`.
|
- Requires the same authenticated owner/admin credential as other chat routes. Responses use `Cache-Control: no-store`.
|
||||||
@@ -341,7 +338,6 @@ Behavior notes:
|
|||||||
- Before the Agent is invoked, the backend paginates and supplies the complete safe visible-thread projection as required initial context; the Agent does not have to choose `read_chat_history` for the current thread. User/assistant asides and full supported attachment representations are included, while system/tool rows and unrelated metadata are excluded. Historical content remains untrusted context, not new authority.
|
- Before the Agent is invoked, the backend paginates and supplies the complete safe visible-thread projection as required initial context; the Agent does not have to choose `read_chat_history` for the current thread. User/assistant asides and full supported attachment representations are included, while system/tool rows and unrelated metadata are excluded. Historical content remains untrusted context, not new authority.
|
||||||
- The initial context transfer is limited to 30 MiB. A stored text attachment marked truncated, incomplete history, or a transfer/provider context-limit failure returns an explicit error and no answer from partial history; providers may reject a complete thread that exceeds their model context window.
|
- The initial context transfer is limited to 30 MiB. A stored text attachment marked truncated, incomplete history, or a transfer/provider context-limit failure returns an explicit error and no answer from partial history; providers may reject a complete thread that exceeds their model context window.
|
||||||
- For `chatId` calls, server stores only *new* non-assistant messages from provided history to avoid duplicates.
|
- For `chatId` calls, server stores only *new* non-assistant messages from provided history to avoid duplicates.
|
||||||
- Saved ordinary assistant responses also store the completion's provider/model in `metadata.generation`, exposed by `GET /v1/chats/:chatId`; changing the chat selection does not relabel previous responses.
|
|
||||||
- `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint.
|
- `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint.
|
||||||
- `enabledTools` limits Sybil-managed tools for this request. When omitted for a saved chat, the stored chat setting is used; otherwise all available tools are enabled by default. An empty array disables Sybil-managed tools.
|
- `enabledTools` limits Sybil-managed tools for this request. When omitted for a saved chat, the stored chat setting is used; otherwise all available tools are enabled by default. An empty array disables Sybil-managed tools.
|
||||||
- `maxTokens` is optional. For `anthropic`, when omitted the backend requests the selected model's maximum output token limit from Anthropic's Models API and uses that as `max_tokens`; if the model limit cannot be loaded, the fallback is 128000. For other providers, omitted `maxTokens` is not sent as an explicit cap.
|
- `maxTokens` is optional. For `anthropic`, when omitted the backend requests the selected model's maximum output token limit from Anthropic's Models API and uses that as `max_tokens`; if the model limit cannot be loaded, the fallback is 128000. For other providers, omitted `maxTokens` is not sent as an explicit cap.
|
||||||
|
|||||||
@@ -146,8 +146,6 @@ Each tool invocation can emit multiple `tool_call` events with the same `toolCal
|
|||||||
|
|
||||||
For `persist: false` streams, `chatId` and `callId` are `null`.
|
For `persist: false` streams, `chatId` and `callId` are `null`.
|
||||||
|
|
||||||
When an ordinary persisted completion finishes, its assistant message stores the same request-time provider/model in `metadata.generation`, available in the canonical `GET /v1/chats/:chatId` refresh. Clients must use per-message generation metadata for historical response labels, not the current chat selection. Older messages can lack attribution; see [chat detail](rest.md#get-v1chatschatid). This adds no SSE events or fields, and does not change non-persistent Quick Questions or inline Agent identity.
|
|
||||||
|
|
||||||
### `delta`
|
### `delta`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -235,6 +235,53 @@ struct AgentAvatar: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct AgentTypingDots: View {
|
||||||
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
TimelineView(.animation(minimumInterval: 1 / 30, paused: reduceMotion)) { timeline in
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
ForEach(0..<3, id: \.self) { index in
|
||||||
|
let pulse = pulse(at: timeline.date, index: index)
|
||||||
|
Circle()
|
||||||
|
.fill(Color(red: 0.80, green: 0.70, blue: 0.91))
|
||||||
|
.frame(width: 3, height: 3)
|
||||||
|
.opacity(reduceMotion ? 0.7 : pulse.opacity)
|
||||||
|
.offset(y: reduceMotion ? 0 : pulse.rise)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(
|
||||||
|
Color(red: 0.12, green: 0.075, blue: 0.21).opacity(0.42),
|
||||||
|
in: RoundedRectangle(cornerRadius: 9)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityLabel("Sybil is thinking")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pulse(at date: Date, index: Int) -> (opacity: Double, rise: CGFloat) {
|
||||||
|
let cycle = 1.4
|
||||||
|
let delay = Double(index) * 0.16 / cycle
|
||||||
|
let elapsed = date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: cycle) / cycle
|
||||||
|
let phase = (elapsed - delay + 1).truncatingRemainder(dividingBy: 1)
|
||||||
|
let intensity: Double
|
||||||
|
if phase <= 0.35 {
|
||||||
|
let progress = phase / 0.35
|
||||||
|
let eased = progress * progress * (3 - 2 * progress)
|
||||||
|
intensity = 0.35 + 0.65 * eased
|
||||||
|
} else if phase <= 0.70 {
|
||||||
|
let progress = (phase - 0.35) / 0.35
|
||||||
|
let eased = progress * progress * (3 - 2 * progress)
|
||||||
|
intensity = 1 - 0.65 * eased
|
||||||
|
} else {
|
||||||
|
intensity = 0.35
|
||||||
|
}
|
||||||
|
return (intensity, -2 * CGFloat((intensity - 0.35) / 0.65))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Activity/read publications still update the transcript. Completed rows depend
|
// Activity/read publications still update the transcript. Completed rows depend
|
||||||
// only on message values and heading; scrolling needs no per-row geometry.
|
// only on message values and heading; scrolling needs no per-row geometry.
|
||||||
private struct AgentTranscriptRow: View, Equatable {
|
private struct AgentTranscriptRow: View, Equatable {
|
||||||
|
|||||||
@@ -234,7 +234,13 @@ struct MessageBubble: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if isStreamingAssistant && !agentStyle {
|
if agentStyle && shouldShowInlineAgentPendingDots(
|
||||||
|
message: message,
|
||||||
|
isSending: isSending,
|
||||||
|
presentation: matchingStreamingAssistantPresentation
|
||||||
|
) {
|
||||||
|
AgentTypingDots()
|
||||||
|
} else if isStreamingAssistant && !agentStyle {
|
||||||
StreamingAssistantContentView(
|
StreamingAssistantContentView(
|
||||||
content: message.content,
|
content: message.content,
|
||||||
isSending: isSending,
|
isSending: isSending,
|
||||||
@@ -289,6 +295,24 @@ struct MessageBubble: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shouldShowInlineAgentPendingDots(
|
||||||
|
message: Message,
|
||||||
|
isSending: Bool,
|
||||||
|
presentation: StreamingAssistantPresentation?
|
||||||
|
) -> Bool {
|
||||||
|
guard message.isAgentAside,
|
||||||
|
message.role == .assistant,
|
||||||
|
message.id.hasPrefix("temp-assistant-"),
|
||||||
|
isSending,
|
||||||
|
let presentation,
|
||||||
|
presentation.messageID == message.id,
|
||||||
|
presentation.isAwaitingVisibleText
|
||||||
|
else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
private struct StreamingTraceHeightPreferenceKey: PreferenceKey {
|
private struct StreamingTraceHeightPreferenceKey: PreferenceKey {
|
||||||
static let defaultValue: CGFloat = 0
|
static let defaultValue: CGFloat = 0
|
||||||
|
|
||||||
|
|||||||
@@ -54,10 +54,14 @@ struct StreamingAssistantState: Equatable, Sendable {
|
|||||||
private(set) var initiatedToolCallIDs: Set<String> = []
|
private(set) var initiatedToolCallIDs: Set<String> = []
|
||||||
private(set) var ephemeralTrace: String?
|
private(set) var ephemeralTrace: String?
|
||||||
private(set) var traceRevision = 0
|
private(set) var traceRevision = 0
|
||||||
|
private(set) var isAwaitingVisibleText = true
|
||||||
|
|
||||||
mutating func receiveDelta(_ text: String) -> StreamingAssistantContentUpdate {
|
mutating func receiveDelta(_ text: String) -> StreamingAssistantContentUpdate {
|
||||||
guard !text.isEmpty else { return .unchanged }
|
guard !text.isEmpty else { return .unchanged }
|
||||||
segmentText += text
|
segmentText += text
|
||||||
|
if !segmentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
isAwaitingVisibleText = false
|
||||||
|
}
|
||||||
ephemeralTrace = nil
|
ephemeralTrace = nil
|
||||||
return .replace(segmentText)
|
return .replace(segmentText)
|
||||||
}
|
}
|
||||||
@@ -79,20 +83,27 @@ struct StreamingAssistantState: Equatable, Sendable {
|
|||||||
mutating func receiveDone(_ text: String) -> StreamingAssistantContentUpdate {
|
mutating func receiveDone(_ text: String) -> StreamingAssistantContentUpdate {
|
||||||
segmentText = ""
|
segmentText = ""
|
||||||
ephemeralTrace = nil
|
ephemeralTrace = nil
|
||||||
|
isAwaitingVisibleText = false
|
||||||
return .replace(text)
|
return .replace(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
mutating func receiveError() -> StreamingAssistantContentUpdate {
|
mutating func receiveError() -> StreamingAssistantContentUpdate {
|
||||||
segmentText = ""
|
segmentText = ""
|
||||||
ephemeralTrace = nil
|
ephemeralTrace = nil
|
||||||
|
isAwaitingVisibleText = false
|
||||||
return .unchanged
|
return .unchanged
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mutating func receiveCancel() {
|
||||||
|
isAwaitingVisibleText = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct StreamingAssistantPresentation: Equatable, Sendable {
|
struct StreamingAssistantPresentation: Equatable, Sendable {
|
||||||
var messageID: String
|
var messageID: String
|
||||||
var ephemeralTrace: String?
|
var ephemeralTrace: String?
|
||||||
var traceRevision: Int
|
var traceRevision: Int
|
||||||
|
var isAwaitingVisibleText: Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct PendingChatState {
|
private struct PendingChatState {
|
||||||
@@ -107,7 +118,8 @@ private struct PendingChatState {
|
|||||||
return StreamingAssistantPresentation(
|
return StreamingAssistantPresentation(
|
||||||
messageID: messageID,
|
messageID: messageID,
|
||||||
ephemeralTrace: streamingAssistantState.ephemeralTrace,
|
ephemeralTrace: streamingAssistantState.ephemeralTrace,
|
||||||
traceRevision: streamingAssistantState.traceRevision
|
traceRevision: streamingAssistantState.traceRevision,
|
||||||
|
isAwaitingVisibleText: streamingAssistantState.isAwaitingVisibleText
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -467,7 +479,8 @@ final class SybilViewModel {
|
|||||||
return StreamingAssistantPresentation(
|
return StreamingAssistantPresentation(
|
||||||
messageID: messageID,
|
messageID: messageID,
|
||||||
ephemeralTrace: quickQuestionStreamingAssistantState.ephemeralTrace,
|
ephemeralTrace: quickQuestionStreamingAssistantState.ephemeralTrace,
|
||||||
traceRevision: quickQuestionStreamingAssistantState.traceRevision
|
traceRevision: quickQuestionStreamingAssistantState.traceRevision,
|
||||||
|
isAwaitingVisibleText: quickQuestionStreamingAssistantState.isAwaitingVisibleText
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2151,6 +2164,13 @@ final class SybilViewModel {
|
|||||||
pendingDraftChatState = PendingChatState(chatID: nil, messages: optimisticMessages)
|
pendingDraftChatState = PendingChatState(chatID: nil, messages: optimisticMessages)
|
||||||
}
|
}
|
||||||
defer {
|
defer {
|
||||||
|
if Task.isCancelled {
|
||||||
|
if let resolvedChatID = chatID {
|
||||||
|
pendingChatStates[resolvedChatID]?.streamingAssistantState.receiveCancel()
|
||||||
|
} else {
|
||||||
|
pendingDraftChatState?.streamingAssistantState.receiveCancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
if let resolvedChatID = chatID {
|
if let resolvedChatID = chatID {
|
||||||
pendingChatStates[resolvedChatID] = nil
|
pendingChatStates[resolvedChatID] = nil
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
import XCTest
|
||||||
|
@testable import Sybil
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AgentInlineThinkingDotsTests: XCTestCase {
|
||||||
|
func testPendingDotsReplaceWithTextAndHideOnDoneFailureOrCancel() async throws {
|
||||||
|
let messageID = "temp-assistant-inline-dots-fixture"
|
||||||
|
let date = Date(timeIntervalSince1970: 1_750_000_000)
|
||||||
|
let user = Message(id: "temp-user-inline-dots-fixture", createdAt: date, role: .user, content: "@Sybil, summarize this.", name: nil)
|
||||||
|
var reply = Message(
|
||||||
|
id: messageID,
|
||||||
|
createdAt: date,
|
||||||
|
role: .assistant,
|
||||||
|
content: "",
|
||||||
|
name: nil,
|
||||||
|
metadata: .object(["agentAside": .bool(true)])
|
||||||
|
)
|
||||||
|
var state = StreamingAssistantState()
|
||||||
|
var presentation = Self.presentation(messageID: messageID, state: state)
|
||||||
|
|
||||||
|
XCTAssertTrue(shouldShowInlineAgentPendingDots(message: reply, isSending: true, presentation: presentation))
|
||||||
|
await capture(messages: [user, reply], presentation: presentation, sending: true, name: "inline-agent-pending-dancing-dots")
|
||||||
|
|
||||||
|
XCTAssertEqual(state.receiveDelta(" \n"), .replace(" \n"))
|
||||||
|
reply.content = state.segmentText
|
||||||
|
presentation = Self.presentation(messageID: messageID, state: state)
|
||||||
|
XCTAssertTrue(shouldShowInlineAgentPendingDots(message: reply, isSending: true, presentation: presentation))
|
||||||
|
|
||||||
|
XCTAssertEqual(state.receiveDelta("Here is the Agent reply."), .replace(" \nHere is the Agent reply."))
|
||||||
|
reply.content = state.segmentText
|
||||||
|
presentation = Self.presentation(messageID: messageID, state: state)
|
||||||
|
XCTAssertFalse(shouldShowInlineAgentPendingDots(message: reply, isSending: true, presentation: presentation))
|
||||||
|
await capture(messages: [user, reply], presentation: presentation, sending: true, name: "inline-agent-first-visible-text-same-bubble")
|
||||||
|
|
||||||
|
XCTAssertEqual(state.receiveDone("Completed Agent reply."), .replace("Completed Agent reply."))
|
||||||
|
reply.content = "Completed Agent reply."
|
||||||
|
presentation = Self.presentation(messageID: messageID, state: state)
|
||||||
|
XCTAssertFalse(shouldShowInlineAgentPendingDots(message: reply, isSending: true, presentation: presentation))
|
||||||
|
XCTAssertEqual(reply.id, messageID)
|
||||||
|
|
||||||
|
var failed = StreamingAssistantState()
|
||||||
|
let failurePresentation = Self.presentation(messageID: messageID, state: failed)
|
||||||
|
XCTAssertTrue(shouldShowInlineAgentPendingDots(message: reply.withContent(""), isSending: true, presentation: failurePresentation))
|
||||||
|
XCTAssertEqual(failed.receiveError(), .unchanged)
|
||||||
|
XCTAssertFalse(shouldShowInlineAgentPendingDots(
|
||||||
|
message: reply.withContent(""), isSending: true, presentation: Self.presentation(messageID: messageID, state: failed)
|
||||||
|
))
|
||||||
|
|
||||||
|
var cancelled = StreamingAssistantState()
|
||||||
|
let cancelPresentation = Self.presentation(messageID: messageID, state: cancelled)
|
||||||
|
XCTAssertTrue(shouldShowInlineAgentPendingDots(message: reply.withContent(""), isSending: true, presentation: cancelPresentation))
|
||||||
|
cancelled.receiveCancel()
|
||||||
|
XCTAssertFalse(shouldShowInlineAgentPendingDots(
|
||||||
|
message: reply.withContent(""), isSending: true, presentation: Self.presentation(messageID: messageID, state: cancelled)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func presentation(messageID: String, state: StreamingAssistantState) -> StreamingAssistantPresentation {
|
||||||
|
StreamingAssistantPresentation(
|
||||||
|
messageID: messageID,
|
||||||
|
ephemeralTrace: state.ephemeralTrace,
|
||||||
|
traceRevision: state.traceRevision,
|
||||||
|
isAwaitingVisibleText: state.isAwaitingVisibleText
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func capture(
|
||||||
|
messages: [Message],
|
||||||
|
presentation: StreamingAssistantPresentation,
|
||||||
|
sending: Bool,
|
||||||
|
name: String
|
||||||
|
) async {
|
||||||
|
let host = UIHostingController(rootView: InlineAgentReplySnapshot(
|
||||||
|
messages: messages,
|
||||||
|
presentation: presentation,
|
||||||
|
sending: sending
|
||||||
|
))
|
||||||
|
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 170))
|
||||||
|
window.rootViewController = host
|
||||||
|
window.makeKeyAndVisible()
|
||||||
|
host.view.frame = window.bounds
|
||||||
|
host.view.setNeedsLayout()
|
||||||
|
host.view.layoutIfNeeded()
|
||||||
|
try? await Task.sleep(for: .milliseconds(80))
|
||||||
|
let image = UIGraphicsImageRenderer(bounds: host.view.bounds).image { _ in
|
||||||
|
XCTAssertTrue(host.view.drawHierarchy(in: host.view.bounds, afterScreenUpdates: true))
|
||||||
|
}
|
||||||
|
if let data = image.pngData() {
|
||||||
|
let attachment = XCTAttachment(data: data, uniformTypeIdentifier: "public.png")
|
||||||
|
attachment.name = name
|
||||||
|
attachment.lifetime = .keepAlways
|
||||||
|
add(attachment)
|
||||||
|
} else {
|
||||||
|
XCTFail("Could not encode (name) screenshot")
|
||||||
|
}
|
||||||
|
window.isHidden = true
|
||||||
|
window.rootViewController = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct InlineAgentReplySnapshot: View {
|
||||||
|
var messages: [Message]
|
||||||
|
var presentation: StreamingAssistantPresentation
|
||||||
|
var sending: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
|
SybilTheme.background.ignoresSafeArea()
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Inline @Sybil reply")
|
||||||
|
.font(.sybil(.caption))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
ForEach(messages) { message in
|
||||||
|
if message.isAgentAside {
|
||||||
|
AgentMessageRow(
|
||||||
|
message: message,
|
||||||
|
isSending: sending,
|
||||||
|
streamingAssistantPresentation: presentation
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(message.content).foregroundStyle(SybilTheme.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
.frame(width: 390, height: 170)
|
||||||
|
.environment(\.colorScheme, .dark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Message {
|
||||||
|
func withContent(_ content: String) -> Message {
|
||||||
|
var copy = self
|
||||||
|
copy.content = content
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,7 +85,6 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
|
|||||||
chatId: call.chatId,
|
chatId: call.chatId,
|
||||||
role: "assistant" as any,
|
role: "assistant" as any,
|
||||||
content: outText,
|
content: outText,
|
||||||
metadata: { generation: { provider: req.provider, model: req.model } },
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await tx.llmCall.update({
|
await tx.llmCall.update({
|
||||||
|
|||||||
@@ -123,10 +123,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
|||||||
chatId,
|
chatId,
|
||||||
role: "assistant" as any,
|
role: "assistant" as any,
|
||||||
content: text,
|
content: text,
|
||||||
metadata: {
|
metadata: req.clientRequestId ? ({ clientRequestId: req.clientRequestId } as any) : undefined,
|
||||||
...(req.clientRequestId ? { clientRequestId: req.clientRequestId } : {}),
|
|
||||||
generation: { provider: req.provider, model: req.model },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await tx.llmCall.update({
|
await tx.llmCall.update({
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import { fromPrismaProvider } from "./llm/provider-ids.js";
|
|
||||||
|
|
||||||
function record(value: unknown): Record<string, unknown> {
|
|
||||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
||||||
}
|
|
||||||
|
|
||||||
type RecordedCall = {
|
|
||||||
provider: unknown;
|
|
||||||
model: string;
|
|
||||||
request: unknown;
|
|
||||||
error: string | null;
|
|
||||||
latencyMs: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Recover older streaming replies only through an unambiguous, completed request ID. */
|
|
||||||
export function withMessageGenerations<T extends { role: string; metadata: unknown }>(messages: T[], calls: RecordedCall[]): T[] {
|
|
||||||
const byRequest = new Map<string, { provider: string; model: string } | null>();
|
|
||||||
for (const call of calls) {
|
|
||||||
const requestId = record(call.request).clientRequestId;
|
|
||||||
if (typeof requestId !== "string" || !requestId || call.error !== null || call.latencyMs === null) continue;
|
|
||||||
const provider = fromPrismaProvider(call.provider);
|
|
||||||
if (!provider || !call.model.trim()) continue;
|
|
||||||
byRequest.set(requestId, byRequest.has(requestId) ? null : { provider, model: call.model });
|
|
||||||
}
|
|
||||||
return messages.map(message => {
|
|
||||||
const metadata = record(message.metadata);
|
|
||||||
if (message.role !== "assistant" || metadata.agentAside === true || metadata.generation !== undefined) return message;
|
|
||||||
const generation = typeof metadata.clientRequestId === "string" ? byRequest.get(metadata.clientRequestId) : null;
|
|
||||||
return generation ? { ...message, metadata: { ...metadata, generation } } : message;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@ import { invokeAgentInline, type AgentInlineInvoker } from "./agent-inline.js";
|
|||||||
import { buildInlineAgentThreadContext, decodeChatHistoryCursor, decodeChatListCursor, encodeChatListCursor, loadCompleteChatHistory, readChatHistoryPage } from "./chat-history.js";
|
import { buildInlineAgentThreadContext, decodeChatHistoryCursor, decodeChatListCursor, encodeChatListCursor, loadCompleteChatHistory, readChatHistoryPage } from "./chat-history.js";
|
||||||
import { containsSybilMention, filterAgentAsideMessages } from "./agent-mention.js";
|
import { containsSybilMention, filterAgentAsideMessages } from "./agent-mention.js";
|
||||||
import { prisma } from "./db.js";
|
import { prisma } from "./db.js";
|
||||||
import { withMessageGenerations } from "./message-generation.js";
|
|
||||||
import { requireAdmin } from "./auth.js";
|
import { requireAdmin } from "./auth.js";
|
||||||
import { env } from "./env.js";
|
import { env } from "./env.js";
|
||||||
import { buildComparableAttachments } from "./llm/message-content.js";
|
import { buildComparableAttachments } from "./llm/message-content.js";
|
||||||
@@ -1174,12 +1173,11 @@ export async function registerRoutes(app: FastifyInstance, options: { agentInlin
|
|||||||
enabledTools: true,
|
enabledTools: true,
|
||||||
userId: true,
|
userId: true,
|
||||||
messages: { orderBy: [{ createdAt: "asc" }, { id: "asc" }] },
|
messages: { orderBy: [{ createdAt: "asc" }, { id: "asc" }] },
|
||||||
calls: { select: { provider: true, model: true, request: true, error: true, latencyMs: true } },
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!source) return { status: "chat-not-found" as const };
|
if (!source) return { status: "chat-not-found" as const };
|
||||||
|
|
||||||
let messages = withMessageGenerations(source.messages, source.calls);
|
let messages = source.messages;
|
||||||
let selectedMessage: (typeof source.messages)[number] | undefined;
|
let selectedMessage: (typeof source.messages)[number] | undefined;
|
||||||
if (parsed.data.messageId) {
|
if (parsed.data.messageId) {
|
||||||
const selectedIndex = messages.findIndex((message) => message.id === parsed.data.messageId);
|
const selectedIndex = messages.findIndex((message) => message.id === parsed.data.messageId);
|
||||||
@@ -1682,7 +1680,7 @@ export async function registerRoutes(app: FastifyInstance, options: { agentInlin
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!chat) return app.httpErrors.notFound("chat not found");
|
if (!chat) return app.httpErrors.notFound("chat not found");
|
||||||
return { chat: serializeChatLike({ ...chat, messages: withMessageGenerations(chat.messages, chat.calls) }) };
|
return { chat: serializeChatLike(chat) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/v1/chats/:chatId/messages", async (req) => {
|
app.post("/v1/chats/:chatId/messages", async (req) => {
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { execFileSync } from "node:child_process";
|
|
||||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { dirname, join, resolve } from "node:path";
|
|
||||||
import test from "node:test";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import Fastify from "fastify";
|
|
||||||
import sensible from "@fastify/sensible";
|
|
||||||
import { withMessageGenerations } from "../src/message-generation.js";
|
|
||||||
|
|
||||||
const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
||||||
const databaseDir = mkdtempSync(join(tmpdir(), "sybil-message-generation-"));
|
|
||||||
process.env.DATABASE_URL = `file:${join(databaseDir, "test.db")}`;
|
|
||||||
process.env.ADMIN_TOKEN = "message-generation-test-token";
|
|
||||||
process.env.OPENAI_API_KEY = "synthetic-openai-key";
|
|
||||||
process.env.XAI_API_KEY = "synthetic-xai-key";
|
|
||||||
writeFileSync(join(databaseDir, "test.db"), "");
|
|
||||||
execFileSync(process.execPath, [join(serverRoot, "node_modules/prisma/build/index.js"), "migrate", "deploy"], {
|
|
||||||
cwd: serverRoot, env: { ...process.env, PRISMA_HIDE_UPDATE_MESSAGE: "1" }, stdio: "pipe",
|
|
||||||
});
|
|
||||||
const [{ registerRoutes }, { prisma }, { runMultiplex }, { runMultiplexStream }] = await Promise.all([
|
|
||||||
import("../src/routes.js"), import("../src/db.js"), import("../src/llm/multiplexer.js"), import("../src/llm/streaming.js"),
|
|
||||||
]);
|
|
||||||
const app = Fastify({ logger: false });
|
|
||||||
await app.register(sensible);
|
|
||||||
await registerRoutes(app);
|
|
||||||
await app.ready();
|
|
||||||
const auth = { authorization: "Bearer message-generation-test-token" };
|
|
||||||
test.after(async () => { await app.close(); await prisma.$disconnect(); rmSync(databaseDir, { recursive: true, force: true }); });
|
|
||||||
|
|
||||||
test("streaming and non-streaming replies retain their own model across selection changes and forks", async t => {
|
|
||||||
t.mock.method(globalThis, "fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
||||||
const url = input instanceof Request ? input.url : String(input);
|
|
||||||
assert.ok(["https://api.openai.com/v1/chat/completions", "https://api.x.ai/v1/chat/completions"].includes(url), url);
|
|
||||||
const body = JSON.parse(input instanceof Request ? await input.text() : String(init?.body));
|
|
||||||
const content = `Reply from ${body.model}`;
|
|
||||||
return body.stream
|
|
||||||
? new Response(`data: ${JSON.stringify({ choices: [{ index: 0, delta: { content }, finish_reason: null }] })}\n\ndata: [DONE]\n\n`, { headers: { "content-type": "text/event-stream" } })
|
|
||||||
: Response.json({ choices: [{ message: { role: "assistant", content } }] });
|
|
||||||
});
|
|
||||||
const chat = await prisma.chat.create({ data: { title: "Mixed models" } });
|
|
||||||
const messages = [{ role: "user" as const, content: "Answer" }];
|
|
||||||
const events = [];
|
|
||||||
for await (const event of runMultiplexStream({ chatId: chat.id, provider: "openai", model: "model-one", clientRequestId: "first-turn", messages, enabledTools: [] })) events.push(event);
|
|
||||||
assert.equal(events.at(-1)?.type, "done");
|
|
||||||
await runMultiplex({ chatId: chat.id, provider: "xai", model: "model-two", messages, enabledTools: [] });
|
|
||||||
await prisma.chat.update({ where: { id: chat.id }, data: { lastUsedProvider: "gemini", lastUsedModel: "model-next" } });
|
|
||||||
const detail = await app.inject({ method: "GET", url: `/v1/chats/${chat.id}`, headers: auth });
|
|
||||||
assert.equal(detail.statusCode, 200);
|
|
||||||
const saved = detail.json().chat.messages;
|
|
||||||
assert.deepEqual(saved.map((m: any) => m.metadata.generation), [{ provider: "openai", model: "model-one" }, { provider: "xai", model: "model-two" }]);
|
|
||||||
assert.equal(saved[0].metadata.clientRequestId, "first-turn");
|
|
||||||
const fork = await app.inject({ method: "POST", url: `/v1/chats/${chat.id}/fork`, headers: auth, payload: { messageId: saved[1].id } });
|
|
||||||
assert.equal(fork.statusCode, 200);
|
|
||||||
const forkDetail = await app.inject({ method: "GET", url: `/v1/chats/${fork.json().chat.id}`, headers: auth });
|
|
||||||
assert.deepEqual(forkDetail.json().chat.messages.map((m: any) => m.metadata.generation), saved.map((m: any) => m.metadata.generation));
|
|
||||||
assert.equal(forkDetail.json().chat.messages[0].metadata.clientRequestId, undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("legacy call matches are projected without rewriting history and survive a new fork", async () => {
|
|
||||||
const chat = await prisma.chat.create({ data: {
|
|
||||||
title: "Older reply", lastUsedProvider: "gemini", lastUsedModel: "different-current-model",
|
|
||||||
messages: { create: { role: "assistant", content: "Older reply", metadata: { clientRequestId: "legacy-request", citations: ["https://example.test"] } } },
|
|
||||||
calls: { create: { provider: "anthropic", model: "original-model", request: { clientRequestId: "legacy-request" }, latencyMs: 10 } },
|
|
||||||
}, include: { messages: true } });
|
|
||||||
const detail = await app.inject({ method: "GET", url: `/v1/chats/${chat.id}`, headers: auth });
|
|
||||||
assert.deepEqual(detail.json().chat.messages[0].metadata.generation, { provider: "anthropic", model: "original-model" });
|
|
||||||
assert.deepEqual((await prisma.message.findUniqueOrThrow({ where: { id: chat.messages[0]!.id } })).metadata, chat.messages[0]!.metadata);
|
|
||||||
const fork = await app.inject({ method: "POST", url: `/v1/chats/${chat.id}/fork`, headers: auth, payload: {} });
|
|
||||||
const forkDetail = await app.inject({ method: "GET", url: `/v1/chats/${fork.json().chat.id}`, headers: auth });
|
|
||||||
assert.deepEqual(forkDetail.json().chat.messages[0].metadata, { citations: ["https://example.test"], generation: { provider: "anthropic", model: "original-model" } });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("missing, ambiguous, failed and unfinished calls never guess a reply's model", () => {
|
|
||||||
const call = { provider: "openai", model: "model-one", request: { clientRequestId: "request" }, error: null, latencyMs: 10 };
|
|
||||||
const message = { role: "assistant", metadata: { clientRequestId: "request" } };
|
|
||||||
for (const calls of [[], [call, { ...call, provider: "xai" }], [{ ...call, error: "failed" }], [{ ...call, latencyMs: null }]]) {
|
|
||||||
assert.deepEqual(withMessageGenerations([message], calls), [message]);
|
|
||||||
}
|
|
||||||
const existing = { ...message, metadata: { ...message.metadata, generation: { provider: "gemini", model: "original" } } };
|
|
||||||
const aside = { ...message, metadata: { ...message.metadata, agentAside: true } };
|
|
||||||
const user = { ...message, role: "user" };
|
|
||||||
assert.deepEqual(withMessageGenerations([existing, aside, user], [call]), [existing, aside, user]);
|
|
||||||
});
|
|
||||||
@@ -50,19 +50,23 @@ function toolName(value: unknown) {
|
|||||||
return typeof value === "string" ? value.replace(/_/g, " ") : "Tool";
|
return typeof value === "string" ? value.replace(/_/g, " ") : "Tool";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AgentBubble = memo(function AgentBubble({ message, animate, onRetry, download, showThinkingIndicator = false }: { message: Message; animate: boolean; onRetry: (id: string) => void; download: AgentRoomHookResult["downloadAttachment"]; showThinkingIndicator?: boolean }) {
|
export function AgentThinkingDots({ label = "Sybil is thinking" }: { label?: string }) {
|
||||||
|
return <span className="agent-room__typing-dots" aria-label={label} role="status"><i /><i /><i /></span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AgentBubble = memo(function AgentBubble({ message, animate, onRetry, download, showThinkingIndicator = false, isPending = false }: { message: Message; animate: boolean; onRetry: (id: string) => void; download: AgentRoomHookResult["downloadAttachment"]; showThinkingIndicator?: boolean; isPending?: boolean }) {
|
||||||
const own = message.role === "user";
|
const own = message.role === "user";
|
||||||
const info = metadata(message);
|
const info = metadata(message);
|
||||||
const status = typeof info.status === "string" ? info.status : "complete";
|
const status = typeof info.status === "string" ? info.status : "complete";
|
||||||
const failed = status === "failed" || status === "unconfirmed";
|
const failed = status === "failed" || status === "unconfirmed";
|
||||||
const thinking = showThinkingIndicator && !own && status === "sending" && !message.content.trim();
|
const thinking = showThinkingIndicator && !own && (isPending ?? status === "sending") && !message.content.trim();
|
||||||
const date = dateOf(message.createdAt);
|
const date = dateOf(message.createdAt);
|
||||||
return (
|
return (
|
||||||
<article className={`agent-room__message ${own ? "is-own" : "is-agent"}`} data-agent-message-id={message.id} data-message-status={status} data-animation={animate ? "enter" : undefined} aria-label={own ? "Your message" : "Sybil's message"}>
|
<article className={`agent-room__message ${own ? "is-own" : "is-agent"}`} data-agent-message-id={message.id} data-message-status={status} data-animation={animate ? "enter" : undefined} aria-label={own ? "Your message" : "Sybil's message"}>
|
||||||
{!own ? <span className="agent-room__message-avatar" aria-hidden="true"><img src="/character-still.png" alt="" draggable={false} /></span> : null}
|
{!own ? <span className="agent-room__message-avatar" aria-hidden="true"><img src="/character-still.png" alt="" draggable={false} /></span> : null}
|
||||||
<div className="agent-room__message-content">
|
<div className="agent-room__message-content">
|
||||||
<div className={`message-bubble${own ? " message-bubble--user" : ""}`}>
|
<div className={`message-bubble${own ? " message-bubble--user" : ""}`}>
|
||||||
{own ? <p className="agent-room__plain-message">{message.content}</p> : thinking ? <span className="inline-flex items-center gap-1" aria-label="Sybil is thinking" 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> : <MarkdownContent markdown={message.content} openLinksInNewTab allowImages={false} />}
|
{own ? <p className="agent-room__plain-message">{message.content}</p> : thinking ? <AgentThinkingDots /> : <MarkdownContent markdown={message.content} openLinksInNewTab allowImages={false} />}
|
||||||
{agentAttachments(message.metadata, message.role).map(file => <AgentAttachmentCard key={file.id} file={file} download={download} inline={message.role === "assistant"} />)}
|
{agentAttachments(message.metadata, message.role).map(file => <AgentAttachmentCard key={file.id} file={file} download={download} inline={message.role === "assistant"} />)}
|
||||||
</div>
|
</div>
|
||||||
{own && failed && typeof info.error === "string" ? <div className="agent-attachment__error" role="alert">{info.error}</div> : null}
|
{own && failed && typeof info.error === "string" ? <div className="agent-attachment__error" role="alert">{info.error}</div> : null}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ 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 { asStreamingAssistantMetadata, isInlineAgentReplyPending } from "@/lib/chat-stream-presentation";
|
||||||
import { AgentBubble } from "@/components/agent/agent-conversation";
|
import { AgentBubble } from "@/components/agent/agent-conversation";
|
||||||
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";
|
||||||
@@ -604,7 +604,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageCon
|
|||||||
if (message.role === "assistant" && metadata.agentAside === true) {
|
if (message.role === "assistant" && metadata.agentAside === true) {
|
||||||
return (
|
return (
|
||||||
<div key={message.id} className="flex justify-start" data-agent-aside="true">
|
<div key={message.id} className="flex justify-start" data-agent-aside="true">
|
||||||
<AgentBubble message={message} animate={message.id.startsWith("temp-assistant-")} onRetry={() => {}} download={noAgentAttachmentDownload} showThinkingIndicator />
|
<AgentBubble message={message} animate={message.id.startsWith("temp-assistant-")} onRetry={() => {}} download={noAgentAttachmentDownload} showThinkingIndicator isPending={isInlineAgentReplyPending(message, isSending)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ import type { Message, ToolCallEvent } from "@/lib/api";
|
|||||||
|
|
||||||
export const STREAMING_ASSISTANT_METADATA_KIND = "streaming_assistant";
|
export const STREAMING_ASSISTANT_METADATA_KIND = "streaming_assistant";
|
||||||
|
|
||||||
|
export function isInlineAgentReplyPending(message: Message, isSending: boolean): boolean {
|
||||||
|
const metadata = message.metadata && typeof message.metadata === "object" && !Array.isArray(message.metadata)
|
||||||
|
? message.metadata as Record<string, unknown> : {};
|
||||||
|
return isSending && message.role === "assistant" && message.id.startsWith("temp-assistant-")
|
||||||
|
&& metadata.agentAside === true && !message.content.trim();
|
||||||
|
}
|
||||||
|
|
||||||
export type StreamingAssistantMetadata = {
|
export type StreamingAssistantMetadata = {
|
||||||
kind: typeof STREAMING_ASSISTANT_METADATA_KIND;
|
kind: typeof STREAMING_ASSISTANT_METADATA_KIND;
|
||||||
ephemeralTrace: string | null;
|
ephemeralTrace: string | null;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
createStreamingAssistantMetadata,
|
createStreamingAssistantMetadata,
|
||||||
createStreamingAttemptState,
|
createStreamingAttemptState,
|
||||||
finalizeStreamingAssistant,
|
finalizeStreamingAssistant,
|
||||||
|
isInlineAgentReplyPending,
|
||||||
markStreamingAssistantAsAgentAside,
|
markStreamingAssistantAsAgentAside,
|
||||||
promoteStreamingAssistantTrace,
|
promoteStreamingAssistantTrace,
|
||||||
setStreamingAssistantSegment,
|
setStreamingAssistantSegment,
|
||||||
@@ -126,6 +127,15 @@ test("Agent aside stream keeps identity through loading, markdown deltas, and co
|
|||||||
assert.equal(messages.at(-1).content, "**Inline** reply");
|
assert.equal(messages.at(-1).content, "**Inline** reply");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("inline Agent pending dots stop on first text, completion, failure, and cancellation", () => {
|
||||||
|
let message = markStreamingAssistantAsAgentAside([assistantMessage()], "temp-assistant-").at(-1);
|
||||||
|
assert.equal(isInlineAgentReplyPending(message, true), true);
|
||||||
|
message = setStreamingAssistantSegment([message], "temp-assistant-", "First visible text").at(-1);
|
||||||
|
assert.equal(isInlineAgentReplyPending(message, true), false);
|
||||||
|
assert.equal(isInlineAgentReplyPending(assistantMessage(), false), false, "failed or cancelled pending state is hidden");
|
||||||
|
assert.equal(isInlineAgentReplyPending({ ...message, id: "saved-agent-reply", content: "" }, true), false);
|
||||||
|
});
|
||||||
|
|
||||||
test("message helpers preserve identity when no matching temporary assistant exists", () => {
|
test("message helpers preserve identity when no matching temporary assistant exists", () => {
|
||||||
const messages = [
|
const messages = [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user