Add inline persistent Agent mentions
TestFlight / Build and upload (push) Successful in 1m57s

This commit is contained in:
Sybil Agent
2026-10-29 18:28:52 -07:00
parent 35a6c537ad
commit 5d738f897b
23 changed files with 1772 additions and 44 deletions
@@ -263,20 +263,33 @@ private struct AgentTranscriptRow: View, Equatable {
}
}
private struct AgentMessageRow: View {
struct AgentMessageRow: View {
var message: Message
var room: SybilAgentRoom
var room: SybilAgentRoom? = nil
var isSending = false
var streamingAssistantPresentation: StreamingAssistantPresentation? = nil
var isForking = false
var onFork: (() -> Void)? = nil
private var own: Bool { message.role == .user }
private var status: String { message.metadata?.objectValue?["status"]?.stringValue ?? "complete" }
var body: some View {
HStack(alignment: .bottom, spacing: 8) {
if !own { AgentAvatar().padding(.bottom, 23) }
VStack(alignment: own ? .trailing : .leading, spacing: 5) {
ForEach(message.agentAttachments) { attachment in
AgentAttachmentCard(attachment: attachment, room: room)
if let room {
ForEach(message.agentAttachments) { attachment in
AgentAttachmentCard(attachment: attachment, room: room)
}
}
if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || message.agentAttachments.isEmpty {
MessageBubble(message: message, isSending: false, streamingAssistantPresentation: nil, isForking: false, onFork: nil, agentStyle: true)
if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || room == nil || message.agentAttachments.isEmpty {
MessageBubble(
message: message,
isSending: isSending,
streamingAssistantPresentation: streamingAssistantPresentation,
isForking: isForking,
onFork: onFork,
agentStyle: true
)
}
HStack(spacing: 6) {
Text(own ? "You" : "Sybil")
@@ -285,7 +298,9 @@ private struct AgentMessageRow: View {
if own && status == "queued" { Label("Queued", systemImage: "clock") }
if own && status == "failed" {
Text("Not confirmed").foregroundStyle(SybilTheme.danger)
Button("Retry") { room.retry(message.id) }.accessibilityIdentifier("agent-retry")
if let room {
Button("Retry") { room.retry(message.id) }.accessibilityIdentifier("agent-retry")
}
}
}
.font(.sybil(.caption2)).foregroundStyle(SybilTheme.textMuted)
@@ -45,16 +45,27 @@ struct SybilChatTranscriptView: View {
ForEach(renderItems) { item in
switch item {
case let .message(message):
MessageBubble(
message: message,
isSending: isSending,
streamingAssistantPresentation: streamingAssistantPresentation,
isForking: isForking,
onFork: {
onForkResponse?(message.id)
}
)
if message.isAgentAside {
AgentMessageRow(
message: message,
isSending: isSending,
streamingAssistantPresentation: streamingAssistantPresentation,
isForking: isForking,
onFork: { onForkResponse?(message.id) }
)
.frame(maxWidth: .infinity)
} else {
MessageBubble(
message: message,
isSending: isSending,
streamingAssistantPresentation: streamingAssistantPresentation,
isForking: isForking,
onFork: {
onForkResponse?(message.id)
}
)
.frame(maxWidth: .infinity)
}
case let .toolGroup(id, messages):
ToolCallStackView(
groupID: id,
@@ -0,0 +1,205 @@
import Foundation
enum SybilInlineMention {
private struct Fence {
var marker: Character
var length: Int
}
static func hasExplicitSybilMention(in text: String) -> Bool {
let lines = text.components(separatedBy: .newlines)
var fence: Fence?
var inBlockquote = false
var paragraph: [String] = []
func paragraphHasMention() -> Bool {
hasMentionOutsideInlineCode(in: paragraph.joined(separator: "\n"))
}
func isBlank(_ line: String) -> Bool {
line.allSatisfy(\.isWhitespace)
}
for line in lines {
if let currentFence = fence {
if let closingFence = fenceMarker(in: line, opening: false),
closingFence.marker == currentFence.marker,
closingFence.length >= currentFence.length {
fence = nil
}
continue
}
if let openingFence = fenceMarker(in: line, opening: true) {
if paragraphHasMention() { return true }
paragraph.removeAll(keepingCapacity: true)
fence = openingFence
continue
}
if isBlank(line) {
if paragraphHasMention() { return true }
paragraph.removeAll(keepingCapacity: true)
inBlockquote = false
continue
}
if isBlockquoteLine(line) {
if paragraphHasMention() { return true }
paragraph.removeAll(keepingCapacity: true)
inBlockquote = true
continue
}
if inBlockquote || isIndentedCodeLine(line) {
if !paragraph.isEmpty {
if paragraphHasMention() { return true }
paragraph.removeAll(keepingCapacity: true)
}
continue
}
paragraph.append(line)
}
return paragraphHasMention()
}
private static func fenceMarker(in line: String, opening: Bool) -> Fence? {
let characters = Array(line)
var index = 0
while index < characters.count, characters[index] == " ", index < 4 {
index += 1
}
guard index <= 3, index < characters.count else { return nil }
let marker = characters[index]
guard marker == "`" || marker == "~" else { return nil }
let start = index
while index < characters.count, characters[index] == marker {
index += 1
}
let length = index - start
guard length >= 3 else { return nil }
if opening {
if marker == "`", characters[index...].contains("`") { return nil }
} else if !characters[index...].allSatisfy(\.isWhitespace) {
return nil
}
return Fence(marker: marker, length: length)
}
private static func isBlockquoteLine(_ line: String) -> Bool {
let characters = Array(line)
var index = 0
while index < characters.count, characters[index] == " ", index < 4 {
index += 1
}
return index <= 3 && index < characters.count && characters[index] == ">"
}
private static func isIndentedCodeLine(_ line: String) -> Bool {
let characters = Array(line)
var index = 0
while index < characters.count, characters[index] == " ", index < 4 {
index += 1
}
return index == 4 || (index < characters.count && characters[index] == "\t")
}
private static func hasMentionOutsideInlineCode(in text: String) -> Bool {
let visible = strippingInlineCode(from: text)
let characters = Array(visible)
guard characters.count >= 6 else { return false }
for index in 0...(characters.count - 6) where characters[index] == "@" {
let token = String(characters[index..<(index + 6)])
guard token.caseInsensitiveCompare("@sybil") == .orderedSame else { continue }
if index > 0, isEmailLocalPartCharacter(characters[index - 1]) { continue }
if index + 6 < characters.count, isWordCharacter(characters[index + 6]) { continue }
return true
}
return false
}
private static func strippingInlineCode(from text: String) -> String {
let characters = Array(text)
var visible: [Character] = []
var index = 0
while index < characters.count {
guard characters[index] == "`" else {
visible.append(characters[index])
index += 1
continue
}
let start = index
while index < characters.count, characters[index] == "`" {
index += 1
}
let runLength = index - start
if hasEscapedBacktick(before: start, in: characters) {
visible.append(contentsOf: characters[start..<index])
continue
}
if let closingStart = matchingBacktickRun(after: index, length: runLength, in: characters) {
index = closingStart + runLength
} else {
visible.append(contentsOf: characters[start..<index])
}
}
return String(visible)
}
private static func matchingBacktickRun(after start: Int, length: Int, in characters: [Character]) -> Int? {
var index = start
while index < characters.count {
guard characters[index] == "`" else {
index += 1
continue
}
let runStart = index
while index < characters.count, characters[index] == "`" {
index += 1
}
if index - runStart == length { return runStart }
}
return nil
}
private static func hasEscapedBacktick(before index: Int, in characters: [Character]) -> Bool {
guard index > 0 else { return false }
var cursor = index
while cursor > 0, characters[cursor - 1] == "\\" {
cursor -= 1
}
return (index - cursor).isMultiple(of: 2) == false
}
private static func isEmailLocalPartCharacter(_ character: Character) -> Bool {
character.isLetter || character.isNumber || "!#$%&'*+-/=?^_`{|}~.".contains(character)
}
private static func isWordCharacter(_ character: Character) -> Bool {
character.isLetter || character.isNumber || character == "_"
}
}
extension Message {
var isAgentAside: Bool {
role == .assistant && metadata?.objectValue?["agentAside"]?.boolValue == true
}
mutating func setAgentAside(_ value: Bool) {
var object = metadata?.objectValue ?? [:]
object["agentAside"] = .bool(value)
metadata = .object(object)
}
}
@@ -627,6 +627,7 @@ public struct CompletionStreamMeta: Codable, Sendable {
public var callId: String?
public var provider: Provider
public var model: String
public var agentAside: Bool? = nil
}
public struct CompletionStreamDelta: Codable, Sendable {
@@ -2121,6 +2121,7 @@ final class SybilViewModel {
}
private func sendChat(content: String, attachments: [ChatAttachment], sendContext: ActiveSendContext) async throws {
let optimisticAgentAside = SybilInlineMention.hasExplicitSybilMention(in: content)
let optimisticUser = Message(
id: "temp-user-\(UUID().uuidString)",
createdAt: Date(),
@@ -2135,7 +2136,8 @@ final class SybilViewModel {
createdAt: Date(),
role: .assistant,
content: "",
name: nil
name: nil,
metadata: optimisticAgentAside ? .object(["agentAside": .bool(true)]) : nil
)
let optimisticMessages = (currentSelectedChat?.messages ?? []) + [optimisticUser, optimisticAssistant]
@@ -2339,6 +2341,9 @@ final class SybilViewModel {
if payload.chatId == chatID {
pendingChatStates[chatID]?.chatID = payload.chatId
}
if let agentAside = payload.agentAside {
setPendingAssistantAgentAside(agentAside, chatID: chatID)
}
case let .toolCall(payload):
upsertPendingToolCallMessage(payload, chatID: chatID)
@@ -2528,6 +2533,17 @@ final class SybilViewModel {
}
}
private func setPendingAssistantAgentAside(_ value: Bool, chatID: String) {
guard var pending = pendingChatStates[chatID],
let assistantIndex = pending.messages.indices.last(where: {
pending.messages[$0].id.hasPrefix("temp-assistant-")
}) else {
return
}
pending.messages[assistantIndex].setAgentAside(value)
pendingChatStates[chatID] = pending
}
private func applyPendingAssistantDelta(_ text: String, chatID: String) {
guard var pending = pendingChatStates[chatID], !pending.messages.isEmpty else {
return
@@ -0,0 +1,61 @@
import Foundation
import Testing
@testable import Sybil
@Suite
struct SybilInlineMentionTests {
@Test func recognizesLiteralCaseInsensitiveTokens() {
#expect(SybilInlineMention.hasExplicitSybilMention(in: "@sybil"))
#expect(SybilInlineMention.hasExplicitSybilMention(in: "Could you help, @SYBIL?"))
#expect(SybilInlineMention.hasExplicitSybilMention(in: "Please ask (@SyBiL), thanks."))
}
@Test func ignoresEmailAddresses() {
#expect(!SybilInlineMention.hasExplicitSybilMention(in: "Send mail to person@sybil.example"))
#expect(!SybilInlineMention.hasExplicitSybilMention(in: "Mailbox: first.last+tag@sybil"))
}
@Test func ignoresInlineFencedAndIndentedCode() {
#expect(!SybilInlineMention.hasExplicitSybilMention(in: "Run `@sybil` after the build."))
#expect(!SybilInlineMention.hasExplicitSybilMention(in: "Inline `example\n@sybil` across lines."))
#expect(!SybilInlineMention.hasExplicitSybilMention(in: "```swift\n@sybil\n```"))
#expect(!SybilInlineMention.hasExplicitSybilMention(in: "~~~\n@sybil\n~~~"))
#expect(!SybilInlineMention.hasExplicitSybilMention(in: " @sybil\n\t@sybil"))
}
@Test func ignoresMarkdownBlockquotesAndLazyContinuation() {
#expect(!SybilInlineMention.hasExplicitSybilMention(in: "> @sybil\ncontinued @sybil"))
#expect(!SybilInlineMention.hasExplicitSybilMention(in: " > @SYBIL"))
#expect(SybilInlineMention.hasExplicitSybilMention(in: "> quoted @sybil\n\n@sybil"))
}
@Test func streamMetaAcceptsOptionalAgentAsideFlag() throws {
let legacyData = Data(#"{"chatId":"chat-1","provider":"openai","model":"test-model"}"#.utf8)
let legacy = try JSONDecoder().decode(CompletionStreamMeta.self, from: legacyData)
#expect(legacy.agentAside == nil)
let asideData = Data(#"{"chatId":"chat-1","provider":"openai","model":"test-model","agentAside":true}"#.utf8)
let aside = try JSONDecoder().decode(CompletionStreamMeta.self, from: asideData)
#expect(aside.agentAside == true)
}
@Test func asidePresentationUsesAssistantMetadata() {
let assistantAside = Message(
id: "message-1",
createdAt: .now,
role: .assistant,
content: "An Agent reply",
name: "Sybil Agent",
metadata: .object(["agentAside": .bool(true)])
)
let ordinaryAssistant = Message(
id: "message-2",
createdAt: .now,
role: .assistant,
content: "A provider reply",
name: nil
)
#expect(assistantAside.isAgentAside)
#expect(!ordinaryAssistant.isAgentAside)
}
}