RFC-045: Bridge-App Session Synchronization
RFC-045: Bridge-App Session Synchronization
Document Information
- RFC ID: RFC-045
- Related PRD: FEAT-045 (Bridge Session Sync)
- Created: 2026-03-01
- Last Updated: 2026-03-01
- Status: Completed
- Author: TBD
Overview
Background
After FEAT-041, the bridge routes all incoming messages to the app’s most recently updated session. The bridge and the app therefore share one active session under normal use. However, when the user sends /clear via Telegram, MessagingChannel.processInboundMessage() calls conversationMapper.createNewConversation() and silently returns. The newly created session becomes the most recent session in the database, but the app’s ChatScreen has no notification of this and continues to display the old session.
This RFC describes a minimal fix: a SharedFlow-based in-process event bus in BridgeStateTracker that emits the new session ID whenever the bridge creates one, with a LaunchedEffect in ChatScreen that subscribes and reinitializes the ViewModel.
Goals
- Emit a session-switch event from the bridge layer when
/clearcreates a new session. - Subscribe in
ChatScreenand callviewModel.initialize(sessionId)upon receiving the event. - Keep the change minimal: no new modules, no new data classes, no DB schema changes.
- Soft-delete the previous session before creating the new one if it contains no messages, preventing empty sessions from accumulating in the session list.
- Track the currently displayed session in
BridgeStateTrackerso the bridge routes messages correctly when the user manually switches sessions in the app without sending a message. - Make “New Conversation” in the app create the session eagerly (consistent with bridge
/clearbehavior), and clean up the previous empty session. - Generate a session title for bridge-initiated sessions (phase 1 truncated title + phase 2 AI title), matching the behavior of app-initiated sessions.
Non-Goals
- Any new persistence layer or inter-process communication.
- Notification when the app is not in the foreground (the existing Room Flow already handles list refresh; the session switch will apply on next foreground).
- Changes to navigation graph or back stack.
- Changes to
SessionListViewModel(the drawer list updates automatically via Room Flow).
Technical Design
Changed Files Overview
bridge/src/main/kotlin/com/oneclaw/shadow/bridge/
└── BridgeStateTracker.kt # MODIFIED (add SharedFlow + activeAppSessionId)
channel/
└── MessagingChannel.kt # MODIFIED (/clear branch emits event)
app/src/main/kotlin/com/oneclaw/shadow/
├── di/
│ └── BridgeModule.kt # MODIFIED (inject new deps into BridgeAgentExecutorImpl)
├── feature/bridge/
│ ├── BridgeAgentExecutorImpl.kt # MODIFIED (title generation)
│ └── BridgeConversationManagerImpl.kt # MODIFIED (empty session cleanup + routing fix)
└── feature/chat/
├── ChatScreen.kt # MODIFIED (LaunchedEffect subscriber)
└── ChatViewModel.kt # MODIFIED (newConversation() + setActiveAppSession)
Detailed Design
Change 1: BridgeStateTracker – Add newSessionFromBridge SharedFlow
BridgeStateTracker is an object (singleton) that already holds observable state shared between the bridge service and the app UI. Adding a SharedFlow here is consistent with its existing role.
// BridgeStateTracker.kt -- additions only
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
object BridgeStateTracker {
// ... existing fields and methods unchanged ...
private val _newSessionFromBridge = MutableSharedFlow<String>(extraBufferCapacity = 1)
val newSessionFromBridge: SharedFlow<String> = _newSessionFromBridge.asSharedFlow()
fun emitNewSessionFromBridge(sessionId: String) {
_newSessionFromBridge.tryEmit(sessionId)
}
}
Design decisions:
extraBufferCapacity = 1: buffers one event so the emission does not drop if no collector is active at the exact moment of emission (e.g., ChatScreen is mid-composition).tryEmit: fire-and-forget; the bridge does not need a confirmation that the UI received the event.SharedFlow(notStateFlow): a session-switch is a one-time event, not a persistent state value.StateFlowwould re-deliver the last session ID to every new collector, causing an unwanted navigation on each recomposition.
Change 2: MessagingChannel – Emit Event on /clear
In processInboundMessage(), the /clear branch already calls conversationMapper.createNewConversation(). Add one line after that call:
// MessagingChannel.kt -- /clear branch (step 4)
if (msg.text.trim() == "/clear") {
val newConversationId = conversationMapper.createNewConversation()
BridgeStateTracker.emitNewSessionFromBridge(newConversationId) // <-- new line
val clearMessage = BridgeMessage(
content = "Conversation cleared. Starting a new conversation.",
timestamp = System.currentTimeMillis()
)
runCatching { sendResponse(msg.externalChatId, clearMessage) }
updateChannelState(newMessage = true)
return
}
No other changes to MessagingChannel.
Change 3: ChatScreen – Subscribe and Reinitialize
Add a LaunchedEffect near the top of the ChatScreen composable, alongside the existing LaunchedEffect blocks, to collect from BridgeStateTracker.newSessionFromBridge:
// ChatScreen.kt -- inside ChatScreen composable
LaunchedEffect(Unit) {
BridgeStateTracker.newSessionFromBridge.collect { sessionId ->
viewModel.initialize(sessionId)
}
}
Design decisions:
LaunchedEffect(Unit): launches once per composition lifetime, which is the correct scope for a persistent subscription.viewModel.initialize(sessionId): this is the same method called when the user manually selects a session from the drawer. Reusing it ensures identical behavior: the ViewModel loads messages for the new session and updatesuiState.sessionId.- No navigation change is needed because
ChatScreenis already the current screen.
Change 4: BridgeConversationManagerImpl – Clean Up Empty Previous Session
Problem: Every /clear command eagerly creates a session record in the database even though it contains no messages. If the user sends /clear repeatedly without having a conversation in between, empty sessions accumulate in the session list.
Context: Unlike the in-app “New Conversation” flow (which is lazy – no DB record is created until the first message is sent), the bridge must create the session eagerly so that viewModel.initialize(sessionId) in ChatScreen can immediately look it up via sessionRepository.getSessionById().
Solution: In createNewConversation(), check whether the current most-recent session is empty before creating the new one. If it is, soft-delete it first. This ensures that at most one empty bridge session exists at any time.
// BridgeConversationManagerImpl.kt -- updated createNewConversation()
override suspend fun createNewConversation(): String {
// If the previous session has no messages, soft-delete it to avoid accumulation
val prevId = sessionRepository.getMostRecentSessionId()
if (prevId != null) {
val prevSession = sessionRepository.getSessionById(prevId)
if (prevSession != null && prevSession.messageCount == 0) {
sessionRepository.softDeleteSession(prevId)
}
}
val agentId = resolveAgentId()
val now = System.currentTimeMillis()
val session = Session(
id = UUID.randomUUID().toString(),
title = "Bridge Conversation",
currentAgentId = agentId,
messageCount = 0,
lastMessagePreview = null,
isActive = false,
deletedAt = null,
createdAt = now,
updatedAt = now
)
val created = sessionRepository.createSession(session)
return created.id
}
Design decisions:
- Uses
session.messageCount(a denormalized field on theSessionmodel) rather than adding a newMessageRepositoryquery. This field is set to0when the bridge creates the session and is only incremented when messages are added; a bridge session created by/clearthat has never received a message will always havemessageCount == 0. - Uses
sessionRepository.softDeleteSession()(soft delete viadeleted_attimestamp), consistent with the rest of the app’s session deletion pattern. The soft-deleted session disappears from both the session list and thegetMostRecentSessionId()query (which already filtersWHERE deleted_at IS NULL). - No change to the
SessionRepositoryorMessageRepositoryinterfaces is required –getSessionById,getMostRecentSessionId, andsoftDeleteSessionall already exist.
Change 5: Correct Bridge Routing on App-Side Session Switch
Problem: BridgeConversationManagerImpl.getActiveConversationId() originally returned only sessionRepository.getMostRecentSessionId() (ordered by updated_at DESC). When the user switched to an older session in the app without sending a message, updated_at was not touched, so the bridge continued routing to the previously newer session.
Solution: Add an in-memory activeAppSessionId to BridgeStateTracker. ChatViewModel.initialize() updates it on every session switch. getActiveConversationId() prefers the in-memory value, falling back to the DB query when the app has not set one (e.g., cold start before the user opens ChatScreen).
// BridgeStateTracker.kt -- additions
private val _activeAppSessionId = MutableStateFlow<String?>(null)
val activeAppSessionId: StateFlow<String?> = _activeAppSessionId.asStateFlow()
fun setActiveAppSession(sessionId: String?) {
_activeAppSessionId.value = sessionId
}
// ChatViewModel.kt -- in initialize()
BridgeStateTracker.setActiveAppSession(sessionId)
// BridgeConversationManagerImpl.kt -- updated getActiveConversationId()
override suspend fun getActiveConversationId(): String? {
return BridgeStateTracker.activeAppSessionId.value
?: sessionRepository.getMostRecentSessionId()
}
Change 6: Eager Session Creation for “New Conversation” in App
Problem: Clicking “New Conversation” in the app called initialize(null), setting sessionId = null and BridgeStateTracker.activeAppSessionId = null. The bridge then fell back to getMostRecentSessionId() and routed messages to the last session that had messages, not the new blank session the user just created.
Solution: Add ChatViewModel.newConversation() which eagerly creates a DB session (same as bridge /clear) and calls initialize(newSession.id). The session is immediately registered in BridgeStateTracker, so the bridge routes to it right away. The empty-session cleanup logic (soft-delete if messages.isEmpty()) is also applied here for consistency.
// ChatViewModel.kt
fun newConversation() {
viewModelScope.launch {
val currentSessionId = _uiState.value.sessionId
if (currentSessionId != null && _uiState.value.messages.isEmpty()) {
sessionRepository.softDeleteSession(currentSessionId)
}
val session = createSessionUseCase(agentId = _uiState.value.currentAgentId)
isFirstMessage = true
firstUserMessageText = null
initialize(session.id)
}
}
ChatScreen now calls viewModel.newConversation() instead of viewModel.initialize(null) from the drawer’s “New Conversation” button.
The sendMessage() lazy-creation fallback (if (sessionId == null)) is retained as a safety path for the cold-start case (init { initialize(null) }). The phase-1 title generation is moved outside the sessionId == null block so it fires for both the lazy path and the eager path:
// sendMessage() -- simplified structure
var sessionId = _uiState.value.sessionId
if (sessionId == null) {
// Fallback: lazy creation for cold-start state
val session = createSessionUseCase(agentId = _uiState.value.currentAgentId)
sessionId = session.id
_uiState.update { it.copy(sessionId = sessionId) }
isFirstMessage = true
}
// Phase 1 title applies to both lazy and eager paths
if (isFirstMessage && firstUserMessageText == null) {
val titleSource = text.ifBlank { ... }
val truncatedTitle = generateTitleUseCase.generateTruncatedTitle(titleSource)
sessionRepository.updateTitle(sessionId!!, truncatedTitle)
firstUserMessageText = text.ifBlank { titleSource }
}
Change 7: Session Title Generation for Bridge-Initiated Sessions
Problem: Sessions created by the bridge (via /clear or createNewConversation()) always kept the title “Bridge Conversation”. App-initiated sessions generate a meaningful title (phase 1: truncated user text; phase 2: AI-generated title). Bridge sessions lacked this.
Solution: BridgeAgentExecutorImpl.executeMessage() checks session.messageCount == 0 before executing to detect the first message. If it is the first message:
- Phase 1: calls
generateTitleUseCase.generateTruncatedTitle(userMessage)andsessionRepository.updateTitle()immediately. - Phase 2: after the AI response, calls
generateTitleUseCase.generateAiTitle()with the response content and model/provider IDs captured fromChatEvent.ResponseComplete.
// BridgeAgentExecutorImpl.kt
override suspend fun executeMessage(...): BridgeMessage? {
val isFirstMessage = (sessionRepository.getSessionById(conversationId)?.messageCount ?: 0) == 0
if (isFirstMessage) {
val truncatedTitle = generateTitleUseCase.generateTruncatedTitle(userMessage)
sessionRepository.updateTitle(conversationId, truncatedTitle)
}
// ... execute sendMessageUseCase, capture lastModelId and lastProviderId from ResponseComplete ...
if (isFirstMessage && content != null && lastModelId != null && lastProviderId != null) {
generateTitleUseCase.generateAiTitle(
sessionId = conversationId,
firstUserMessage = userMessage,
firstAiResponse = content,
currentModelId = lastModelId!!,
currentProviderId = lastProviderId!!
)
}
...
}
BridgeAgentExecutorImpl receives two new constructor dependencies: SessionRepository and GenerateTitleUseCase, injected via BridgeModule.
Testing
Unit Tests
Modified files and their test coverage:
BridgeStateTracker: no existing unit tests (it is a simple state holder); the newSharedFlowandStateFlowfields follow the same pattern as existing fields.MessagingChannel(MessagingChannelTest): existing tests cover the/clearbranch. Update the test to verify thatBridgeStateTracker.newSessionFromBridgeemits the new session ID after a/clearmessage.BridgeConversationManagerImpl: add a test case forcreateNewConversation()when the previous session hasmessageCount == 0– verifysoftDeleteSession()is called before the new session is created. Add a second case wheremessageCount > 0– verifysoftDeleteSession()is NOT called.ChatScreen: covered by UI/Roborazzi tests; no change to visual layout, so no new screenshot baseline is needed.ChatViewModel: no existing unit tests fornewConversation(); the function follows the same pattern asinitialize()and is exercised by manual verification step 7 below.BridgeAgentExecutorImpl(BridgeAgentExecutorImplTest): existing tests mockSessionRepositoryandGenerateTitleUseCase. The test suite already instantiates the executor with both new dependencies (added as part of this RFC). Existing tests setsession.messageCount = 1to exercise the non-first-message path; add a new case withmessageCount = 0to verify phase-1 title generation is called.
Manual Verification
- Open the app to
ChatScreen, confirm it shows the current active session. - Send
/clearvia Telegram. - Within 3 seconds, verify that
ChatScreenswitches to a new, empty session. - Send a normal message via Telegram. Verify it appears in the same new session in the app.
- Manually switch to an older session in the app drawer. Send a message via Telegram. Verify it appears in the older session (not the newer one).
- Send
/clearvia Telegram twice in a row without sending any message in between. Verify that only one empty session appears in the session list (the first empty session is soft-deleted when the second/clearis processed). - Tap “New Conversation” in the app drawer. Verify a new empty session is immediately created and displayed. Send a message via Telegram. Verify the message appears in that new session.
- Send a bridge message into a new session (after
/clear). Verify the session title changes from “Bridge Conversation” to a truncated version of the first user message, and later updates to an AI-generated title.
Migration Notes
- No database schema changes.
- No API changes to
BridgeConversationManager,SessionRepository, or any repository interface. BridgeStateTrackergains four new public members:newSessionFromBridge(SharedFlow),emitNewSessionFromBridge(),activeAppSessionId(StateFlow), andsetActiveAppSession(). TheChatScreenandChatViewModelaccess these through the existing shared dependency onBridgeStateTracker.BridgeConversationManagerImpl.createNewConversation()gains pre-creation cleanup logic viasoftDeleteSession(). The behavior change is observable only when the previous session hasmessageCount == 0; all other cases are unaffected.BridgeConversationManagerImpl.getActiveConversationId()now prefersBridgeStateTracker.activeAppSessionIdover the DB query. This changes routing behavior only when the app has explicitly set an active session; cold-start fallback togetMostRecentSessionId()is unchanged.ChatViewModel.initialize()now callsBridgeStateTracker.setActiveAppSession(sessionId)on every session switch. This is a side-effect-only addition; ViewModel UI state behavior is unchanged.ChatViewModelgains a newnewConversation()function.ChatScreennow calls this instead ofinitialize(null)from the drawer’s “New Conversation” button.BridgeAgentExecutorImplreceives two new constructor dependencies (SessionRepository,GenerateTitleUseCase), injected viaBridgeModule. Callers that constructBridgeAgentExecutorImpldirectly (e.g., unit tests) must provide these dependencies.