SAM Messaging Architecture
March 13, 2026 · View on GitHub
Version: 2.0
Last Updated: November 28, 2025
Author: GitHub Copilot
Table of Contents
- Overview
- Architecture Principles
- Core Components
- Complete Data Flow
- Message Types
- State Management
- Performance Optimizations
- Critical Patterns
- Common Issues
- Testing & Debugging
Overview
SAM's messaging architecture implements a single source of truth pattern with unidirectional data flow to eliminate bidirectional synchronization issues and race conditions.
Key Design Goals
- Instant Reactivity: UI updates <100ms after state changes
- No Race Conditions: Single source of truth, unidirectional flow
- Persistence: Non-blocking, debounced saves to disk
- Scalability: Handle 40+ TPS (tokens per second) streaming
- Debuggability: Extensive diagnostic logging
Architecture Principles
1. Single Source of Truth
ConversationMessageBus.messages ← SINGLE SOURCE OF TRUTH
↓
ChatWidget.messages (computed property, read-only)
↓
UI renders messages
ANTI-PATTERN:
ConversationModel.messages ⟷ ConversationMessageBus.messages ← BIDIRECTIONAL (WRONG!)
2. Unidirectional Data Flow
AgentOrchestrator → MessageBus → ChatWidget → ToolExecutionCard
(create) (store) (render) (display)
Rule: Data flows ONE WAY. UI components NEVER modify message state.
3. @MainActor Isolation
All message-related components run on MainActor:
@MainActor
public class ConversationMessageBus: ObservableObject { ... }
@MainActor
public class ConversationModel: ObservableObject { ... }
// ChatWidget is a View, implicitly @MainActor
public struct ChatWidget: View { ... }
Why: Eliminates threading issues, guarantees synchronous UI updates.
4. ObservableObject + @Published Pattern
@MainActor
public class ConversationMessageBus: ObservableObject {
@Published public private(set) var messages: [EnhancedMessage] = []
// ↑ ↑
// Reactive Read-only (setter private)
}
How it works:
- Modify
messagesarray @Publishedwrapper firesobjectWillChange.send()- SwiftUI re-evaluates views observing this object
- UI updates automatically
Core Components
1. ConversationMessageBus (@MainActor, ObservableObject)
Purpose: Central message store and coordinator
Responsibilities:
- Store messages in-memory (
@Published var messages) - Provide message CRUD operations
- Trigger non-blocking persistence
- Sync to ConversationModel (for persistence)
- Cache message indices for O(1) lookup
Key Methods:
// Add new message (streaming or complete)
func appendMessage(_ message: EnhancedMessage)
// Update existing message (during streaming)
func updateMessage(id: UUID, content: String, ...)
// Add tool execution message
func addToolMessage(id: UUID, name: String, status: ToolStatus, ...)
// Streaming updates (throttled to 30 FPS for performance)
func streamDelta(to messageId: UUID, content: String)
// Load from disk (on conversation open)
func loadInitialMessages()
Performance Features:
- Fast lookup:
messageCache: [UUID: Int]maps message ID → array index (O(1)) - Debounced saves: Batch disk writes every 500ms during streaming
- Throttled syncs: Limit ConversationModel syncs to 1000/sec (1ms intervals)
- Delta syncs: Update single message instead of copying entire array
2. ConversationModel (@MainActor, ObservableObject)
Purpose: Persistent conversation container
Responsibilities:
- Own ConversationMessageBus instance
- Store conversation metadata (title, settings, etc.)
- Subscribe to MessageBus changes
- Provide JSON serialization
Key Properties:
@MainActor
public class ConversationModel: ObservableObject {
public let id: UUID
public var title: String
public var settings: ConversationSettings
// MessageBus instance (owned by conversation)
public var messageBus: ConversationMessageBus?
// COMPUTED: Messages synced FROM MessageBus
@Published public var messages: [EnhancedMessage] = []
// Sync from MessageBus
public func syncMessagesFromMessageBus() {
guard let messageBus = self.messageBus else { return }
messages = messageBus.messages
objectWillChange.send()
}
}
CRITICAL: messages is synced FROM MessageBus, not the other way around!
3. ChatWidget (View, implicitly @MainActor)
Purpose: Main chat UI component
Responsibilities:
- Observe MessageBus for changes
- Render message list
- Handle user input
- Route messages to appropriate view components
Key Properties:
public struct ChatWidget: View {
let activeConversation: ConversationModel?
@ObservedObject private var messageBus: ConversationMessageBus
// COMPUTED PROPERTY: Always read from MessageBus
private var messages: [EnhancedMessage] {
messageBus.messages ← SINGLE SOURCE OF TRUTH
}
var body: some View {
ForEach(messages) { message in
// Render message based on type
}
}
}
CRITICAL: messages is computed property, NOT @State!
4. Message Views (MessageView, ToolExecutionCard, etc.)
Purpose: Render individual messages
Responsibilities:
- Display message content
- Handle user interactions (copy, export, expand)
- Show loading/streaming states
Message Routing:
struct MessageView: View {
let message: EnhancedMessage
var body: some View {
switch message.type {
case .user:
UserMessageBubble(message: message)
case .assistant:
AssistantMessageBubble(message: message)
case .toolExecution:
ToolExecutionCard(message: message)
case .thinking:
ThinkingCard(message: message)
case .systemStatus:
SystemStatusCard(message: message)
}
}
}
5. AgentOrchestrator (@MainActor)
Purpose: Coordinate AI agents and tool execution
Responsibilities:
- Call LLM APIs
- Execute tools (parallel or sequential)
- Create messages via MessageBus
- Stream responses in real-time
- Handle user collaboration
Message Creation Pattern:
// User message
conversation.messageBus?.appendMessage(EnhancedMessage(
id: UUID(),
type: .user,
content: userInput,
isFromUser: true
))
// Assistant message (streaming)
let assistantId = UUID()
conversation.messageBus?.appendMessage(EnhancedMessage(
id: assistantId,
type: .assistant,
content: "",
isStreaming: true
))
// Stream deltas
for chunk in streamingResponse {
conversation.messageBus?.streamDelta(to: assistantId, content: chunk)
}
// Mark complete
conversation.messageBus?.finalizeMessage(id: assistantId)
// Tool message
conversation.messageBus?.addToolMessage(
id: UUID(),
name: "file_operations",
status: .running,
details: [],
toolCallId: execution.toolCallId
)
Complete Data Flow
User Sends Message
```mermaid
flowchart TD
A[[User types]]
B[[Hello]]
A --> B
### LLM Streams Response
flowchart TD A[[LLM API sends streaming chunks]] B[[chunk1: Hello]] C[[chunk2: ! How]] D[[chunk3: can I]] E[[chunk4: help?]] A --> B A --> C A --> D A --> E
### Tool Execution Flow
flowchart TD
A[[LLM responds with tool_calls in response]]
B[[tool_calls: [{]]
C[[id: call_123,]]
D[[name: file_operations,]]
E[[arguments: { prompt: a cat }]]
F[[}]]]
A --> B
A --> C
A --> D
A --> E
A --> F
---
## Message Types
### EnhancedMessage Structure
```swift
public struct EnhancedMessage: Identifiable, Codable, Equatable {
// Core identity
public let id: UUID
public let timestamp: Date
public var lastModified: Date?
// Message type and content
public var type: MessageType // .user, .assistant, .toolExecution, etc.
public var content: String
public var isFromUser: Bool
// Streaming state
public var isStreaming: Bool
// Tool execution metadata
public var toolName: String?
public var toolIcon: String?
public var toolStatus: ToolStatus? // .running, .success, .error
public var toolDuration: TimeInterval?
public var toolDetails: [String]?
public var toolMetadata: [String: String]?
public var toolDisplayData: ToolDisplayData?
public var toolCallId: String?
public var parentToolName: String?
// Reasoning/thinking content (for o1 models)
public var reasoningContent: String?
// Multi-modal support
public var contentParts: [MessageContentPart]?
// Image generation results
public var imageURLs: [URL]?
}
Message Types Enum
public enum MessageType: String, Codable {
case user // User input
case assistant // LLM response
case toolExecution // Tool call/result (memory ops, web research, etc.)
case thinking // Reasoning tokens (o1 models)
case systemStatus // System notifications
}
CRITICAL: All tool-related messages use .toolExecution type!
Tool Status Enum
public enum ToolStatus: String, Codable {
case running // Tool currently executing
case success // Tool completed successfully
case error // Tool failed
}
Message Lifecycle States
```mermaid
flowchart TD
A[[CREATED │ ← appendMessage()]]
---
## State Management
### State Ownership
ConversationMessageBus ├─ messages: [EnhancedMessage] ← PRIMARY STATE (owned) ├─ messageCache: [UUID: Int] ← INDEX CACHE (owned) └─ persistenceQueue ← SAVE QUEUE (owned)
ConversationModel ├─ messageBus: MessageBus ← OWNS INSTANCE ├─ messages: [EnhancedMessage] ← SYNCED COPY (for persistence) ├─ title: String ← CONVERSATION METADATA (owned) └─ settings: Settings ← SETTINGS (owned)
ChatWidget ├─ messageBus: MessageBus ← OBSERVES (@ObservedObject) ├─ messages: [Message] ← COMPUTED PROPERTY (read-only) └─ activeConversation: Model ← OBSERVES (for metadata)
### State Synchronization Rules
**✅ CORRECT:**
```swift
// MessageBus → ConversationModel (one-way sync)
conversation.syncMessagesFromMessageBus() {
messages = messageBus.messages // Copy for persistence
}
// ChatWidget reads from MessageBus (computed property)
private var messages: [EnhancedMessage] {
messageBus.messages // Always fresh
}
❌ WRONG:
// NEVER sync ConversationModel → MessageBus
messageBus.messages = conversation.messages // BIDIRECTIONAL (BAD!)
// NEVER store messages in ChatWidget state
@State private var messages: [EnhancedMessage] // STALE DATA (BAD!)
Persistence Strategy
Non-blocking, debounced saves:
private func scheduleSave() {
saveTimer?.invalidate()
saveTimer = Timer.scheduledTimer(
withTimeInterval: 0.5, // 500ms debounce
repeats: false
) { _ in
self.saveToConversation()
}
}
private func saveToConversation() {
persistenceQueue.async { // Background thread
guard let conversation = self.conversation else { return }
// Sync messages to conversation
await MainActor.run {
conversation.syncMessagesFromMessageBus()
}
// Save conversation JSON to disk
try? conversationManager.saveConversation(conversation)
}
}
Why debounce:
- Prevents disk I/O on every streaming chunk (40+ writes/sec)
- Batches saves during rapid updates
- Max 2 saves/second during streaming
When to force immediate save:
- Conversation closes
- App terminates
- Tool execution completes
- User explicitly saves
Performance Optimizations
1. Message Index Cache
Problem: Finding message by ID in array is O(n)
Solution: Maintain index cache
private var messageCache: [UUID: Int] = [:]
func appendMessage(_ message: EnhancedMessage) {
messages.append(message)
messageCache[message.id] = messages.count - 1 // O(1) lookup
}
func getMessage(id: UUID) -> EnhancedMessage? {
guard let index = messageCache[id] else { return nil }
return messages[index] // O(1) access
}
Rebuild cache when:
- Loading messages from disk
- Array is sorted/reordered
- Messages are deleted (indices change)
2. Streaming Throttle (30 FPS)
Problem: 40 TPS streaming causes 40 UI updates/second → UI churn
Solution: Throttle updates to 30 FPS (33ms intervals)
func streamDelta(to messageId: UUID, content: String) {
let now = Date()
// Throttle: Only update if 33ms passed since last update
if let last = lastDeltaSyncTime,
now.timeIntervalSince(last) < 0.033 {
// Too soon, skip this update
return
}
lastDeltaSyncTime = now
// Update message
if let index = messageCache[messageId] {
messages[index].content = content
objectWillChange.send()
}
}
Result:
- Smooth streaming at 30 FPS (human perception limit)
- Reduced CPU/GPU usage
- No visual stuttering
3. Debounced Persistence (500ms)
Problem: Saving on every chunk causes excessive disk I/O
Solution: Batch saves every 500ms
private var saveTimer: Timer?
func scheduleSave() {
saveTimer?.invalidate() // Cancel pending save
saveTimer = Timer.scheduledTimer(
withTimeInterval: 0.5,
repeats: false
) { _ in
self.saveToConversation()
}
}
Result:
- Max 2 disk writes/second during streaming
- Reduced SSD wear
- Lower CPU usage
4. Tool Hierarchy Caching
Problem: Building parent-child tool relationships is O(n²)
Solution: Cache and only rebuild on messages change
@State private var cachedToolHierarchy: [UUID: [EnhancedMessage]] = [:]
.onChange(of: messages) { _, newMessages in
cachedToolHierarchy = buildToolHierarchy(messages: newMessages)
}
// Use cached hierarchy in view
ForEach(messages) { message in
if message.isToolMessage {
ToolMessageWithChildren(
message: message,
children: cachedToolHierarchy[message.id] ?? []
)
}
}
Result:
- Hierarchy built once per messages change
- No recalculation during typing, scrolling, etc.
5. Stable View IDs (Anti-Flicker)
Problem: Using content hash in view ID causes recreation on content change
Solution: Use stable UUID-based IDs
// WRONG: View recreated on every content change
.id("\(message.id)-\(message.content.hashValue)")
// CORRECT: Stable ID throughout message lifecycle
.id(message.id.uuidString)
Result:
- No bubble collapse/expand flicker during streaming
- Smooth content updates within existing view
- Better performance (no view recreation)
6. Forced @Published Trigger for Tool Messages
Problem: @Published doesn't always detect array mutations
Solution: Reassign array to force value change
messages.append(message)
messages = messages // Force @Published to trigger
Why needed: SwiftUI detects VALUE changes, not CONTENT changes
Critical Patterns
Pattern 1: Tool Message Creation
ALWAYS use this pattern for tool messages:
// Step 1: Create message with EMPTY content, running status
let toolMessageId = messageBus.addToolMessage(
id: UUID(),
name: "file_operations",
status: .running, // CRITICAL: Start as running
details: [],
toolDisplayData: nil,
toolCallId: execution.toolCallId
)
// Step 2: Execute tool in background
let result = await executeTool(name: "file_operations", args: args)
// Step 3: Update message with results
messageBus.updateMessage(
id: toolMessageId,
content: result.output, // Fill in content NOW
status: result.success ? .success : .error,
duration: result.duration
)
CRITICAL: Don't wait for tool to complete before creating message!
Pattern 2: Streaming Response
ALWAYS use this pattern for streaming:
// Step 1: Create empty message, mark as streaming
let assistantId = UUID()
messageBus.appendMessage(EnhancedMessage(
id: assistantId,
type: .assistant,
content: "",
isStreaming: true // CRITICAL: Mark as streaming
))
// Step 2: Stream chunks (throttled automatically)
for await chunk in stream {
messageBus.streamDelta(to: assistantId, content: chunk)
}
// Step 3: Finalize (mark streaming complete)
messageBus.finalizeMessage(id: assistantId)
Pattern 3: Message Filtering in UI
ALWAYS use this pattern for filtering:
let isEmpty = message.content.isEmpty &&
message.type != .toolExecution && // NEVER filter tool cards
(message.type != .thinking || (message.reasoningContent == nil || message.reasoningContent!.isEmpty)) &&
(message.contentParts == nil || message.contentParts!.isEmpty)
if !isEmpty {
// Render message
}
CRITICAL: Tool execution messages MUST be exempt from empty filter!
Pattern 4: ObservedObject + Computed Property
ALWAYS use this pattern in views:
public struct ChatWidget: View {
@ObservedObject private var messageBus: ConversationMessageBus
// Computed property, NOT @State
private var messages: [EnhancedMessage] {
messageBus.messages ← SINGLE SOURCE OF TRUTH
}
var body: some View {
ForEach(messages) { message in
// Render
}
}
}
CRITICAL: Use @ObservedObject + computed property, NOT @State!
Common Issues
Issue 1: Messages Not Appearing
Symptom: New messages don't appear in UI
Causes:
@Stateinstead of@ObservedObject- Empty message filter blocking toolExecution
@Publishednot firing
Diagnosis:
# Check if message added to array
grep "CHAT_RENDER_LOOP" sam_server.log | grep <messageId>
# Check if objectWillChange fired
grep "IMMEDIATE_RENDER" sam_server.log | grep <messageId>
Fix:
- Use
@ObservedObject+ computed property - Exempt toolExecution from isEmpty filter
- Force @Published with array reassignment
Issue 2: Messages Appearing Late (Delayed)
Symptom: Tool cards appear 2-6 seconds after creation
Causes:
- Empty filter blocking render
- Async Task delaying sync
- View filtering out message
Diagnosis:
# Check timing between creation and render
grep "IMMEDIATE_RENDER\|TOOL_CARD_RENDERED" sam_server.log | \
grep <messageId>
# Expect < 100ms delta
Fix:
- Exempt toolExecution from isEmpty filter
- Use synchronous sync (not Task { @MainActor })
- Verify message passes all filters
Issue 3: Streaming Flickering
Symptom: Message bubbles collapse/expand during streaming
Causes:
- Content hash in view ID
- View recreated on content change
Diagnosis:
// Check if view ID includes content
.id("\(message.id)-\(message.content.hashValue)") // BAD!
Fix:
// Use stable ID
.id(message.id.uuidString) // GOOD!
Issue 4: Missing Tool Output
Symptom: Tool cards show "Operation completed successfully" instead of actual output
Causes:
- SUCCESS messages filtered as "progress indicators"
- Content not displayed in Output section
Diagnosis:
// Check if SUCCESS messages are filtered
let isProgressMessage = message.content.hasPrefix("SUCCESS: ")
if !isProgressMessage {
// Show output ← SUCCESS never shown!
}
Fix:
// Only filter "→ " progress indicators
let isProgressIndicator = message.content.hasPrefix("→ ")
if !isProgressIndicator {
MarkdownText(message.content) // Shows SUCCESS
}
Issue 5: Tool Card Titles Changing
Symptom: Title changes from "Image Generation" to "SUCCESS: ..."
Causes:
- Content parsing prioritized over toolName
- Title extracted from SUCCESS message
Diagnosis:
// Check getOperationDisplayName priority
if content.hasPrefix("SUCCESS: ") {
return extractedAction // Parses content FIRST
}
return getToolDisplayName(toolName) // Never reached!
Fix:
// Prefer toolName over content parsing
if let toolName = toolName {
return getToolDisplayName(toolName) // Use this FIRST
}
// Content parsing only as fallback
Testing & Debugging
Diagnostic Logging
Enable verbose logging:
// MessageBus
logger.debug("IMMEDIATE_RENDER: Forced array reassignment id=\(id)")
logger.debug("IMMEDIATE_SYNC: Syncing synchronously id=\(id)")
// ChatWidget
logger.info("[CHAT_RENDER_LOOP] Processing msg=\(id), len=\(len), type=\(type)")
logger.info("[FILTER_CHECK] msg=\(id), willRender=\(willRender)")
// ToolExecutionCard
logger.error("TOOL_CARD_RENDERED: tool=\(toolName) id=\(id)")
Search logs:
# Full message lifecycle
grep "IMMEDIATE_RENDER\|IMMEDIATE_SYNC\|RENDER_LOOP\|TOOL_CARD_RENDERED" sam_server.log
# Specific message
grep "<message-id-prefix>" sam_server.log
# Timing analysis
grep "TS:[0-9]" sam_server.log | grep <tool-call-id>
Performance Monitoring
Measure render timing:
// MessageBus
let perfStart = CFAbsoluteTimeGetCurrent()
defer {
InternalOperationMonitor.shared.record("MessageBus.appendMessage",
duration: CFAbsoluteTimeGetCurrent() - perfStart)
}
Expected timings:
MessageBus.appendMessage: < 1msConversationModel.syncMessagesFromMessageBus: < 5ms- Tool card render (IMMEDIATE_RENDER → TOOL_CARD_RENDERED): < 100ms
- Streaming chunk update: < 33ms (30 FPS)
Test Checklist
Message Creation:
- User messages appear instantly (<100ms)
- Tool messages appear instantly, even when empty
- Streaming messages update smoothly (no flicker)
- Messages persist after app restart
Tool Execution:
- Tool cards appear when execution STARTS (not completes)
- Tool card titles stay stable (don't change to content)
- SUCCESS messages appear in Output section
- Error tool cards auto-expand
- Tool duration displays correctly
Performance:
- Streaming at 40 TPS doesn't lag UI
- No excessive disk I/O during streaming
- Tool hierarchy builds quickly
- Large conversations (100+ messages) scroll smoothly
Related Documentation
docs/TOOL_CARD_ARCHITECTURE.md- Detailed tool card documentationdocs/MESSAGE_CREATION_FLOW.md- Legacy message flow documentationSources/ConversationEngine/ConversationMessageBus.swift- Core message busSources/ConversationEngine/ConversationModel.swift- Conversation containerSources/UserInterface/Chat/ChatWidget.swift- Main chat UISources/UserInterface/Chat/MessageView.swift- Message renderingSources/APIFramework/AgentOrchestrator.swift- Message creation
Future Improvements
1. Incremental Loading
Load messages in chunks for large conversations:
func loadMessages(offset: Int, limit: Int) -> [EnhancedMessage]
2. Message Pagination
Render only visible messages + buffer:
@State private var visibleRange: Range<Int>
3. Optimistic Updates
Show user messages immediately, retry on failure:
func appendMessage(_ message: EnhancedMessage, optimistic: Bool = false)
4. Message Diffing
Only re-render changed messages:
func diff(old: [EnhancedMessage], new: [EnhancedMessage]) -> [Change]
5. Structured Tool Results
Replace string parsing with structured data:
message.toolResult = ToolResult(
success: true,
outputs: [.image(url), .text(content)],
metadata: [:]
)
Conclusion
SAM's messaging architecture prioritizes:
- Single Source of Truth - Eliminates sync issues
- Unidirectional Flow - Data flows one way (create → store → render)
- Instant Reactivity - UI updates <100ms via @Published/@ObservedObject
- Performance - Throttling, caching, debouncing for smooth UX
- Debuggability - Extensive logging for diagnosing issues
Remember: Messages flow FROM MessageBus TO UI, never backwards!
Key Takeaway: Tool messages are created EMPTY and filled in later - all filtering and rendering logic must account for this lifecycle.