RFC-041: Messaging Bridge Improvements
RFC-041: Messaging Bridge Improvements
Document Information
- RFC ID: RFC-041
- Related PRD: FEAT-041 (Bridge Improvements)
- Created: 2026-03-01
- Last Updated: 2026-03-01 (Fix 3 corrected 2026-03-01 via RFC-045)
- Status: Completed
- Author: TBD
Overview
Background
After the initial Messaging Bridge implementation (RFC-024), user testing revealed three categories of issues: (1) Telegram message formatting with excessive blank lines, (2) typing indicator appearing after agent processing instead of before, and (3) bridge messages going to a dedicated bridge-only session instead of the app’s active session. This RFC documents the technical changes to resolve these issues plus additional reliability improvements.
Goals
- Rewrite TelegramHtmlRenderer from regex-based to AST visitor pattern for correct formatting
- Fix typing indicator timing to show before agent execution
- Route bridge messages to the app’s most recently used session
- Add plain text fallback for HTML rendering failures
- Consolidate all bridge improvements into documentation
Non-Goals
- New channel implementations
- Rich media responses from agent to platforms
- Database schema migrations
Technical Design
Changed Files Overview
bridge/src/main/kotlin/com/oneclaw/shadow/bridge/
├── BridgeConversationManager.kt # MODIFIED (suspend fun)
├── channel/
│ ├── ConversationMapper.kt # MODIFIED (remove preferences)
│ ├── MessagingChannel.kt # MODIFIED (typing order)
│ └── telegram/
│ ├── TelegramApi.kt # MODIFIED (nullable parseMode)
│ ├── TelegramChannel.kt # MODIFIED (object + fallback)
│ └── TelegramHtmlRenderer.kt # REWRITTEN (AST visitor)
├── service/
│ └── MessagingBridgeService.kt # MODIFIED (mapper construction)
app/src/main/kotlin/com/oneclaw/shadow/
├── core/repository/
│ └── SessionRepository.kt # MODIFIED (new method)
├── data/
│ ├── local/dao/
│ │ └── SessionDao.kt # MODIFIED (new query)
│ └── repository/
│ └── SessionRepositoryImpl.kt # MODIFIED (new method)
└── feature/bridge/
└── BridgeConversationManagerImpl.kt # MODIFIED (active session)
bridge/src/test/kotlin/com/oneclaw/shadow/bridge/
├── channel/
│ ├── ConversationMapperTest.kt # REWRITTEN
│ ├── MessagingChannelTest.kt # MODIFIED
│ └── telegram/
│ └── TelegramHtmlRendererTest.kt # REWRITTEN
Detailed Design
Fix 1: TelegramHtmlRenderer Rewrite
Problem: The original renderer used a two-step process: markdown -> HTML (via commonmark HtmlRenderer) -> Telegram HTML (via regex replacement). The regex approach blindly appended \n\n after every <p> and <h> tag, causing excessive blank lines.
Solution: Replace with direct AST visitor pattern. Parse markdown into commonmark AST, then walk the tree with a custom AbstractVisitor subclass that emits Telegram-compatible HTML directly.
Key design decisions:
- Changed from
classtoobject(stateless singleton, thread-safe) TelegramHtmlVisitorextendsAbstractVisitorwith overrides for all relevant node typesappendBlockSeparator(node): Only adds\nwhennode.next != null; adds\n\nonly for top-level blocks (parent isDocumentorBlockQuote)- List items: Unwraps inner
Paragraphnodes to avoid extra newlines within list items - Blockquotes: Uses native
<blockquote>tag (not<i>workaround); strips trailing newlines before closing tag - Ordered lists: Tracks counter, renders
1.,2.etc. - Thematic break: Renders as 8x horizontal box drawing character (U+2500)
- HTML escaping via
escapeHtml()for&,<,> splitForTelegram()moved to companion object
Before (regex approach):
class TelegramHtmlRenderer {
fun render(markdown: String): String {
val html = HtmlRenderer.builder().build().render(parser.parse(markdown))
return convertToTelegramHtml(html) // regex replacements
}
}
After (AST visitor):
object TelegramHtmlRenderer {
fun render(markdown: String): String {
val document = parser.parse(markdown)
val visitor = TelegramHtmlVisitor()
document.accept(visitor)
return visitor.result().trimEnd()
}
}
Fix 2: Typing Indicator Timing
Problem: In processInboundMessage(), agentExecutor.executeMessage() was called synchronously (blocking via .collect()) BEFORE launching the typing indicator coroutine. The user never saw “typing…” because it started after the agent already finished.
Solution: Reorder the operations:
Before (broken): After (fixed):
agentExecutor.executeMessage() [BLOCKS] Launch typing indicator coroutine
Launch typing [too late!] scope.launch { agentExecutor.executeMessage() }
Await response [immediate] Await response via messageObserver
Cancel typing Cancel typing
The typing coroutine now starts immediately. agentExecutor.executeMessage() is wrapped in scope.launch { } so it runs concurrently. The messageObserver.awaitNextAssistantMessage() call still awaits the actual response with a 300-second timeout.
Fix 3: Active Session Integration
Problem: Bridge messages went to a dedicated bridge-only session stored in BridgePreferences.getBridgeConversationId(). This session was invisible in the app’s UI and disconnected from the user’s workflow.
Solution: Use the app’s currently active session as the bridge target, falling back to the most recently updated session in the database when the app has not explicitly set one.
Interface change – BridgeConversationManager:
// Before
fun getActiveConversationId(): String?
// After
suspend fun getActiveConversationId(): String?
New DAO query – SessionDao:
@Query("SELECT id FROM sessions WHERE deleted_at IS NULL ORDER BY updated_at DESC LIMIT 1")
suspend fun getMostRecentSessionId(): String?
New repository method – SessionRepository + SessionRepositoryImpl:
suspend fun getMostRecentSessionId(): String?
Implementation – BridgeConversationManagerImpl (see correction note below):
override suspend fun getActiveConversationId(): String? {
return BridgeStateTracker.activeAppSessionId.value
?: sessionRepository.getMostRecentSessionId()
}
Simplified ConversationMapper (removed BridgePreferences dependency):
class ConversationMapper(
private val conversationManager: BridgeConversationManager
) {
suspend fun resolveConversationId(): String {
val activeId = conversationManager.getActiveConversationId()
if (activeId != null && conversationManager.conversationExists(activeId)) {
return activeId
}
return createNewConversation()
}
suspend fun createNewConversation(): String {
return conversationManager.createNewConversation()
}
}
Correction (RFC-045): The initial implementation of getActiveConversationId() returned only sessionRepository.getMostRecentSessionId(), which is ordered by updated_at DESC. This worked for messages sent via the bridge (which update updated_at) and for /clear (which creates a newer session). However, it did not correctly handle the case where the user switches to an older session in the app without sending a message: the updated_at of the selected session was not touched, so the bridge continued routing to the previously newer session.
This was corrected as part of RFC-045 by:
- Adding
activeAppSessionId: StateFlow<String?>andsetActiveAppSession()toBridgeStateTracker - Having
ChatViewModel.initialize(sessionId)callBridgeStateTracker.setActiveAppSession(sessionId)on every session switch (including manual selection from the drawer) - Changing
getActiveConversationId()to preferBridgeStateTracker.activeAppSessionId.valueover the DB query, falling back togetMostRecentSessionId()only when the app has never set an active session (e.g., first launch before the user opens ChatScreen)
Fix 4: Plain Text Fallback
TelegramChannel.sendResponse() now wraps HTML rendering in try/catch:
override suspend fun sendResponse(externalChatId: String, message: BridgeMessage) {
val htmlText = try {
TelegramHtmlRenderer.render(message.content)
} catch (e: Exception) {
null
}
if (htmlText != null) {
val parts = TelegramHtmlRenderer.splitForTelegram(htmlText)
parts.forEach { api.sendMessage(chatId = externalChatId, text = it, parseMode = "HTML") }
} else {
val parts = TelegramHtmlRenderer.splitForTelegram(message.content)
parts.forEach { api.sendMessage(chatId = externalChatId, text = it, parseMode = null) }
}
}
TelegramApi.sendMessage() updated to accept nullable parseMode:
suspend fun sendMessage(chatId: String, text: String, parseMode: String? = "HTML")
Testing
Unit Tests
- TelegramHtmlRendererTest: Rewritten with exact
assertEqualsassertions covering paragraphs, headings, lists (ordered and unordered), blockquotes, code blocks, thematic breaks, links, HTML escaping, mixed content, and message splitting. - ConversationMapperTest: Rewritten to test against
getActiveConversationId()instead ofpreferences.getBridgeConversationId(). Removed allBridgePreferencesmock interactions. - MessagingChannelTest: Updated test for agent execution verification.
Manual Verification
- Send message via Telegram, verify response has compact formatting
- Verify typing indicator shows in Telegram while agent processes
- Verify bridge messages appear in the app’s most recently used session
- Send
/clearvia Telegram, verify new session is created - Reboot device, verify bridge auto-starts
Migration Notes
- No database schema changes required
ConversationMapperconstructor signature changed: removedBridgePreferencesparameterBridgeConversationManager.getActiveConversationId()changed fromfuntosuspend funTelegramHtmlRendererchanged fromclasstoobject– callers no longer instantiate it