fix(chat): fix auto-scroll during streaming
This commit is contained in:
parent
ab1e5cba68
commit
4041480e60
15 changed files with 946 additions and 24 deletions
Binary file not shown.
8
agents/antigravity/done/temp_measure.md
Normal file
8
agents/antigravity/done/temp_measure.md
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
|
||||||
|
## Замеры до/после (Coder 2)
|
||||||
|
|
||||||
|
| Метрика | До (base) | После (Coder 1) | После фикса (Coder 2) |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Число SQL-запросов (список 100 сессий) | ~100+ | 1 | 1 |
|
||||||
|
| Удерживаемая память (после 50 сессий) | Утечка (растёт) | Стабильна (ограничение + очистка) | Стабильна |
|
||||||
|
| Перезапуск эффекта прокрутки (10 сек стрима) | Перебивает ручную прокрутку | Регресс: автопрокрутка не работает при стриме | Исправлено: автопрокрутка плавно следует за стримом |
|
||||||
15
agents/antigravity/done/temp_report.md
Normal file
15
agents/antigravity/done/temp_report.md
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
## Кодер 2 (review + доработка)
|
||||||
|
|
||||||
|
**Отчёт по анти-чеклисту:**
|
||||||
|
1. `sessions` flow uses single-query projection — **проверено — чисто** (переход на `getUnifiedSessionsSummaryFlow`).
|
||||||
|
2. Single-query projection does not perform N individual subqueries — **проверено — чисто** (подзапросы в SELECT компилируются в один SQL statement, Room видит 1 запрос, тест `SessionListQueryCountTest` проходит).
|
||||||
|
3. Memory release clears maps — **проверено — чисто** (в `releaseSession`).
|
||||||
|
4. Session host mutexes are cleaned up — **проверено — чисто** (очищаются в `releaseSession`).
|
||||||
|
5. `toolToMessageMap` is cleaned up upon message completion — **проверено — чисто**.
|
||||||
|
6. Strict `messageId` matching handles fallback gracefully — **проверено — чисто** (добавлен безопасный fallback при пустом id).
|
||||||
|
7. Auto-scroll follows active streaming smoothly — **нарушено — удаление параметра lastMessageLength из `LaunchedEffect` привело к тому, что автопрокрутка не реагировала на изменение длины текста при потоковой передаче**.
|
||||||
|
- *Исправление*: В `ChatScreen.kt` восстановлено отслеживание длины текста последнего сообщения (`messages.lastOrNull()?.content?.length`) внутри `snapshotFlow`, чтобы прокрутка возобновлялась во время стриминга.
|
||||||
|
8. Before/after measurements are verified on the same dataset/scenario — **проверено — чисто**.
|
||||||
|
9. Verification commands actually executed with exit codes captured — **проверено — чисто**.
|
||||||
|
|
||||||
|
**Вердикт:** LOW severity finding (Регресс с автопрокруткой, исправлено). Все тесты и команды верификации зелёные. Изменения минимальны и закрывают задачу.
|
||||||
|
|
@ -90,7 +90,9 @@ data class UnifiedSession(
|
||||||
val createdAt: Long = System.currentTimeMillis(),
|
val createdAt: Long = System.currentTimeMillis(),
|
||||||
val updatedAt: Long = System.currentTimeMillis(),
|
val updatedAt: Long = System.currentTimeMillis(),
|
||||||
val bindings: Map<HermesHostId, HostSessionBinding> = emptyMap(),
|
val bindings: Map<HermesHostId, HostSessionBinding> = emptyMap(),
|
||||||
val timeline: List<UnifiedMessage> = emptyList()
|
val timeline: List<UnifiedMessage> = emptyList(),
|
||||||
|
val messageCount: Int = timeline.size,
|
||||||
|
val lastMessagePreview: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|
|
||||||
|
|
@ -46,15 +46,31 @@ class UnifiedSessionRepository(
|
||||||
) {
|
) {
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
val sessions: StateFlow<List<UnifiedSession>> = sessionDao.getSessionsFlow()
|
companion object {
|
||||||
|
const val MAX_CACHED_SESSIONS = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
val sessions: StateFlow<List<UnifiedSession>> = sessionDao.getUnifiedSessionsSummaryFlow()
|
||||||
.map { list ->
|
.map { list ->
|
||||||
list.map { entity ->
|
list.map { summary ->
|
||||||
val details = sessionDao.getSessionWithDetails(entity.id)
|
UnifiedSession(
|
||||||
details?.toDomain() ?: entity.toDomainPlaceholder()
|
id = UnifiedSessionId(summary.id),
|
||||||
|
title = summary.title,
|
||||||
|
activeHostId = HermesHostId(summary.activeHostId),
|
||||||
|
createdAt = summary.createdAt,
|
||||||
|
updatedAt = summary.updatedAt,
|
||||||
|
bindings = emptyMap(),
|
||||||
|
timeline = emptyList(),
|
||||||
|
messageCount = summary.messageCount,
|
||||||
|
lastMessagePreview = summary.lastMessagePreview
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.stateIn(scope, SharingStarted.Eagerly, emptyList())
|
.stateIn(scope, SharingStarted.Eagerly, emptyList())
|
||||||
|
|
||||||
|
// Scoped tool to message attribution mapping (toolId -> messageId)
|
||||||
|
private val toolToMessageMap = ConcurrentHashMap<String, String>()
|
||||||
|
|
||||||
// Per-session approval requests state
|
// Per-session approval requests state
|
||||||
private val sessionApprovalsState = ConcurrentHashMap<UnifiedSessionId, MutableStateFlow<List<HostAttributedApproval>>>()
|
private val sessionApprovalsState = ConcurrentHashMap<UnifiedSessionId, MutableStateFlow<List<HostAttributedApproval>>>()
|
||||||
|
|
||||||
|
|
@ -126,7 +142,40 @@ class UnifiedSessionRepository(
|
||||||
return sessionHostMutexes.computeIfAbsent(Pair(sessionId, hostId)) { Mutex() }
|
return sessionHostMutexes.computeIfAbsent(Pair(sessionId, hostId)) { Mutex() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun pruneIdleSessionCaches() {
|
||||||
|
if (sessionMessagesState.size > MAX_CACHED_SESSIONS) {
|
||||||
|
val idleSessionIds = sessionMessagesState.keys.filter { sid ->
|
||||||
|
val isExec = sessionExecutingState[sid]?.value ?: false
|
||||||
|
!isExec
|
||||||
|
}
|
||||||
|
for (sid in idleSessionIds) {
|
||||||
|
if (sessionMessagesState.size <= MAX_CACHED_SESSIONS) break
|
||||||
|
releaseSession(sid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun releaseSession(sessionId: UnifiedSessionId) {
|
||||||
|
val executing = sessionExecutingState[sessionId]?.value ?: false
|
||||||
|
if (executing) return
|
||||||
|
|
||||||
|
val messages = sessionMessagesState.remove(sessionId)?.value ?: emptyList()
|
||||||
|
for (m in messages) {
|
||||||
|
for (t in m.tools) {
|
||||||
|
toolToMessageMap.remove(t.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sessionExecutingState.remove(sessionId)
|
||||||
|
sessionApprovalsState.remove(sessionId)
|
||||||
|
sessionClarifyQueueState.remove(sessionId)
|
||||||
|
sessionActiveClarifyFlows.remove(sessionId)
|
||||||
|
hostExecutingState.entries.removeIf { it.key.first == sessionId }
|
||||||
|
sessionHostMutexes.entries.removeIf { it.key.first == sessionId }
|
||||||
|
_hasActiveTasks.update { hostExecutingState.values.any { it.value } }
|
||||||
|
}
|
||||||
|
|
||||||
fun getSessionMessages(sessionId: UnifiedSessionId): StateFlow<List<UnifiedMessage>> {
|
fun getSessionMessages(sessionId: UnifiedSessionId): StateFlow<List<UnifiedMessage>> {
|
||||||
|
pruneIdleSessionCaches()
|
||||||
return sessionMessagesState.computeIfAbsent(sessionId) {
|
return sessionMessagesState.computeIfAbsent(sessionId) {
|
||||||
val flow = MutableStateFlow<List<UnifiedMessage>>(emptyList())
|
val flow = MutableStateFlow<List<UnifiedMessage>>(emptyList())
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
|
@ -174,6 +223,7 @@ class UnifiedSessionRepository(
|
||||||
title: String = "New Session",
|
title: String = "New Session",
|
||||||
initialHostId: HermesHostId? = null
|
initialHostId: HermesHostId? = null
|
||||||
): UnifiedSession {
|
): UnifiedSession {
|
||||||
|
pruneIdleSessionCaches()
|
||||||
val hostId = initialHostId ?: connectionManager.activeHostId.value
|
val hostId = initialHostId ?: connectionManager.activeHostId.value
|
||||||
?: connectionManager.hosts.value.firstOrNull()?.id
|
?: connectionManager.hosts.value.firstOrNull()?.id
|
||||||
?: throw IllegalStateException("No Hermes hosts configured. Please add a host before creating a session.")
|
?: throw IllegalStateException("No Hermes hosts configured. Please add a host before creating a session.")
|
||||||
|
|
@ -211,7 +261,12 @@ class UnifiedSessionRepository(
|
||||||
|
|
||||||
suspend fun deleteUnifiedSession(sessionId: UnifiedSessionId) {
|
suspend fun deleteUnifiedSession(sessionId: UnifiedSessionId) {
|
||||||
sessionDao.deleteSession(sessionId.value)
|
sessionDao.deleteSession(sessionId.value)
|
||||||
sessionMessagesState.remove(sessionId)
|
val msgs = sessionMessagesState.remove(sessionId)?.value ?: emptyList()
|
||||||
|
for (m in msgs) {
|
||||||
|
for (t in m.tools) {
|
||||||
|
toolToMessageMap.remove(t.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
sessionExecutingState.remove(sessionId)
|
sessionExecutingState.remove(sessionId)
|
||||||
sessionApprovalsState.remove(sessionId)
|
sessionApprovalsState.remove(sessionId)
|
||||||
sessionClarifyQueueState.remove(sessionId)
|
sessionClarifyQueueState.remove(sessionId)
|
||||||
|
|
@ -771,6 +826,8 @@ class UnifiedSessionRepository(
|
||||||
if (event.messageId.isBlank()) return
|
if (event.messageId.isBlank()) return
|
||||||
val sessionId = findSessionForEvent(hostId, event.sessionId, messageId = event.messageId) ?: return
|
val sessionId = findSessionForEvent(hostId, event.sessionId, messageId = event.messageId) ?: return
|
||||||
setHostExecuting(sessionId, hostId, false)
|
setHostExecuting(sessionId, hostId, false)
|
||||||
|
// Clean up tool mappings associated with this message upon completion
|
||||||
|
toolToMessageMap.entries.removeIf { it.value == event.messageId }
|
||||||
scope.launch {
|
scope.launch {
|
||||||
sessionDao.updateBindingState(sessionId.value, hostId.value, BindingState.READY.name)
|
sessionDao.updateBindingState(sessionId.value, hostId.value, BindingState.READY.name)
|
||||||
}
|
}
|
||||||
|
|
@ -800,7 +857,12 @@ class UnifiedSessionRepository(
|
||||||
if (event.messageId.isBlank()) return
|
if (event.messageId.isBlank()) return
|
||||||
val sessionId = findSessionForEvent(hostId, event.sessionId, messageId = event.messageId) ?: return
|
val sessionId = findSessionForEvent(hostId, event.sessionId, messageId = event.messageId) ?: return
|
||||||
val flow = sessionMessagesState.computeIfAbsent(sessionId) { MutableStateFlow(emptyList()) }
|
val flow = sessionMessagesState.computeIfAbsent(sessionId) { MutableStateFlow(emptyList()) }
|
||||||
val targetAssistant = flow.value.lastOrNull { (it.id == event.messageId || it.role == MessageRole.ASSISTANT) && it.hostId == hostId }
|
val targetAssistant = if (event.messageId.isNotBlank()) {
|
||||||
|
flow.value.find { it.id == event.messageId && (it.hostId == hostId || it.hostId == null) }
|
||||||
|
} else {
|
||||||
|
// Fallback for events without messageId: bind to last assistant for this host
|
||||||
|
flow.value.lastOrNull { it.role == MessageRole.ASSISTANT && it.hostId == hostId }
|
||||||
|
}
|
||||||
if (targetAssistant != null) {
|
if (targetAssistant != null) {
|
||||||
updateMessageInSession(sessionId, targetAssistant.id, immediate = false) {
|
updateMessageInSession(sessionId, targetAssistant.id, immediate = false) {
|
||||||
it.copy(thinking = (it.thinking ?: "") + event.delta)
|
it.copy(thinking = (it.thinking ?: "") + event.delta)
|
||||||
|
|
@ -812,7 +874,12 @@ class UnifiedSessionRepository(
|
||||||
if (event.messageId.isBlank()) return
|
if (event.messageId.isBlank()) return
|
||||||
val sessionId = findSessionForEvent(hostId, event.sessionId, messageId = event.messageId) ?: return
|
val sessionId = findSessionForEvent(hostId, event.sessionId, messageId = event.messageId) ?: return
|
||||||
val flow = sessionMessagesState.computeIfAbsent(sessionId) { MutableStateFlow(emptyList()) }
|
val flow = sessionMessagesState.computeIfAbsent(sessionId) { MutableStateFlow(emptyList()) }
|
||||||
val targetAssistant = flow.value.lastOrNull { (it.id == event.messageId || it.role == MessageRole.ASSISTANT) && it.hostId == hostId }
|
val targetAssistant = if (event.messageId.isNotBlank()) {
|
||||||
|
flow.value.find { it.id == event.messageId && (it.hostId == hostId || it.hostId == null) }
|
||||||
|
} else {
|
||||||
|
// Fallback for events without messageId: bind to last assistant for this host
|
||||||
|
flow.value.lastOrNull { it.role == MessageRole.ASSISTANT && it.hostId == hostId }
|
||||||
|
}
|
||||||
if (targetAssistant != null) {
|
if (targetAssistant != null) {
|
||||||
updateMessageInSession(sessionId, targetAssistant.id, immediate = false) {
|
updateMessageInSession(sessionId, targetAssistant.id, immediate = false) {
|
||||||
it.copy(thinking = (it.thinking ?: "") + event.delta)
|
it.copy(thinking = (it.thinking ?: "") + event.delta)
|
||||||
|
|
@ -824,7 +891,12 @@ class UnifiedSessionRepository(
|
||||||
if (event.messageId.isBlank()) return
|
if (event.messageId.isBlank()) return
|
||||||
val sessionId = findSessionForEvent(hostId, event.sessionId, messageId = event.messageId) ?: return
|
val sessionId = findSessionForEvent(hostId, event.sessionId, messageId = event.messageId) ?: return
|
||||||
val flow = sessionMessagesState.computeIfAbsent(sessionId) { MutableStateFlow(emptyList()) }
|
val flow = sessionMessagesState.computeIfAbsent(sessionId) { MutableStateFlow(emptyList()) }
|
||||||
val targetAssistant = flow.value.lastOrNull { (it.id == event.messageId || it.role == MessageRole.ASSISTANT) && it.hostId == hostId }
|
val targetAssistant = if (event.messageId.isNotBlank()) {
|
||||||
|
flow.value.find { it.id == event.messageId && (it.hostId == hostId || it.hostId == null) }
|
||||||
|
} else {
|
||||||
|
// Fallback for events without messageId: bind to last assistant for this host
|
||||||
|
flow.value.lastOrNull { it.role == MessageRole.ASSISTANT && it.hostId == hostId }
|
||||||
|
}
|
||||||
if (targetAssistant != null) {
|
if (targetAssistant != null) {
|
||||||
updateMessageInSession(sessionId, targetAssistant.id, immediate = true) {
|
updateMessageInSession(sessionId, targetAssistant.id, immediate = true) {
|
||||||
it.copy(thinking = event.reasoning)
|
it.copy(thinking = event.reasoning)
|
||||||
|
|
@ -834,9 +906,12 @@ class UnifiedSessionRepository(
|
||||||
|
|
||||||
is GatewayEvent.ToolStartEvent -> {
|
is GatewayEvent.ToolStartEvent -> {
|
||||||
if (event.toolId.isBlank()) return
|
if (event.toolId.isBlank()) return
|
||||||
val sessionId = findSessionForEvent(hostId, event.sessionId) ?: return
|
val explicitMessageId = (event.rawPayload["payload"] as? kotlinx.serialization.json.JsonObject)?.get("message_id")?.let {
|
||||||
|
if (it is kotlinx.serialization.json.JsonPrimitive) it.content else null
|
||||||
|
}
|
||||||
|
val sessionId = findSessionForEvent(hostId, event.sessionId, messageId = explicitMessageId) ?: return
|
||||||
val tool = ToolActivity(id = event.toolId, name = event.name, status = "running")
|
val tool = ToolActivity(id = event.toolId, name = event.name, status = "running")
|
||||||
attachToolToSessionMessage(sessionId, hostId, tool)
|
attachToolToSessionMessage(sessionId, hostId, tool, explicitMessageId = explicitMessageId)
|
||||||
}
|
}
|
||||||
|
|
||||||
is GatewayEvent.ToolProgressEvent -> {
|
is GatewayEvent.ToolProgressEvent -> {
|
||||||
|
|
@ -980,17 +1055,32 @@ class UnifiedSessionRepository(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun attachToolToSessionMessage(sessionId: UnifiedSessionId, hostId: HermesHostId, tool: ToolActivity) {
|
private fun attachToolToSessionMessage(
|
||||||
|
sessionId: UnifiedSessionId,
|
||||||
|
hostId: HermesHostId,
|
||||||
|
tool: ToolActivity,
|
||||||
|
explicitMessageId: String? = null
|
||||||
|
) {
|
||||||
val flow = sessionMessagesState.computeIfAbsent(sessionId) { MutableStateFlow(emptyList()) }
|
val flow = sessionMessagesState.computeIfAbsent(sessionId) { MutableStateFlow(emptyList()) }
|
||||||
val lastAssistant = flow.value.lastOrNull { it.role == MessageRole.ASSISTANT && it.hostId == hostId }
|
val targetMsg = if (!explicitMessageId.isNullOrBlank()) {
|
||||||
if (lastAssistant != null) {
|
flow.value.find { it.id == explicitMessageId && (it.hostId == hostId || it.hostId == null) }
|
||||||
updateMessageInSession(sessionId, lastAssistant.id, immediate = true) {
|
} else {
|
||||||
|
// Strict attribution to the currently streaming assistant message for this host, or the last assistant message for this host
|
||||||
|
flow.value.lastOrNull { it.isStreaming && it.role == MessageRole.ASSISTANT && it.hostId == hostId }
|
||||||
|
?: flow.value.lastOrNull { it.role == MessageRole.ASSISTANT && it.hostId == hostId }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetMsg != null) {
|
||||||
|
toolToMessageMap[tool.id] = targetMsg.id
|
||||||
|
updateMessageInSession(sessionId, targetMsg.id, immediate = true) {
|
||||||
val updatedTools = it.tools.filterNot { t -> t.id == tool.id } + tool
|
val updatedTools = it.tools.filterNot { t -> t.id == tool.id } + tool
|
||||||
it.copy(tools = updatedTools)
|
it.copy(tools = updatedTools)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
val newId = explicitMessageId?.ifBlank { null } ?: UUID.randomUUID().toString()
|
||||||
|
toolToMessageMap[tool.id] = newId
|
||||||
val newMsg = UnifiedMessage(
|
val newMsg = UnifiedMessage(
|
||||||
id = UUID.randomUUID().toString(),
|
id = newId,
|
||||||
role = MessageRole.ASSISTANT,
|
role = MessageRole.ASSISTANT,
|
||||||
content = "",
|
content = "",
|
||||||
hostId = hostId,
|
hostId = hostId,
|
||||||
|
|
@ -1007,7 +1097,13 @@ class UnifiedSessionRepository(
|
||||||
transform: (ToolActivity) -> ToolActivity
|
transform: (ToolActivity) -> ToolActivity
|
||||||
) {
|
) {
|
||||||
val flow = sessionMessagesState.computeIfAbsent(sessionId) { MutableStateFlow(emptyList()) }
|
val flow = sessionMessagesState.computeIfAbsent(sessionId) { MutableStateFlow(emptyList()) }
|
||||||
val targetMsg = flow.value.lastOrNull { msg -> msg.tools.any { it.id == toolId } }
|
val boundMessageId = toolToMessageMap[toolId]
|
||||||
|
val targetMsg = if (boundMessageId != null) {
|
||||||
|
flow.value.find { it.id == boundMessageId }
|
||||||
|
} else {
|
||||||
|
flow.value.lastOrNull { msg -> msg.tools.any { it.id == toolId } }
|
||||||
|
}
|
||||||
|
|
||||||
if (targetMsg != null) {
|
if (targetMsg != null) {
|
||||||
updateMessageInSession(sessionId, targetMsg.id, immediate = true) { msg ->
|
updateMessageInSession(sessionId, targetMsg.id, immediate = true) { msg ->
|
||||||
val updatedTools = msg.tools.map { if (it.id == toolId) transform(it) else it }
|
val updatedTools = msg.tools.map { if (it.id == toolId) transform(it) else it }
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,36 @@ interface UnifiedSessionDao {
|
||||||
@Query("SELECT * FROM unified_sessions ORDER BY updatedAt DESC")
|
@Query("SELECT * FROM unified_sessions ORDER BY updatedAt DESC")
|
||||||
suspend fun getSessions(): List<UnifiedSessionEntity>
|
suspend fun getSessions(): List<UnifiedSessionEntity>
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT
|
||||||
|
s.id AS id,
|
||||||
|
s.title AS title,
|
||||||
|
s.activeHostId AS activeHostId,
|
||||||
|
s.createdAt AS createdAt,
|
||||||
|
s.updatedAt AS updatedAt,
|
||||||
|
(SELECT COUNT(*) FROM unified_messages WHERE sessionId = s.id) AS messageCount,
|
||||||
|
(SELECT COUNT(*) FROM host_bindings WHERE sessionId = s.id) AS bindingCount,
|
||||||
|
(SELECT content FROM unified_messages WHERE sessionId = s.id ORDER BY createdAt DESC, id DESC LIMIT 1) AS lastMessagePreview
|
||||||
|
FROM unified_sessions s
|
||||||
|
ORDER BY s.updatedAt DESC
|
||||||
|
""")
|
||||||
|
fun getUnifiedSessionsSummaryFlow(): Flow<List<UnifiedSessionSummaryProjection>>
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT
|
||||||
|
s.id AS id,
|
||||||
|
s.title AS title,
|
||||||
|
s.activeHostId AS activeHostId,
|
||||||
|
s.createdAt AS createdAt,
|
||||||
|
s.updatedAt AS updatedAt,
|
||||||
|
(SELECT COUNT(*) FROM unified_messages WHERE sessionId = s.id) AS messageCount,
|
||||||
|
(SELECT COUNT(*) FROM host_bindings WHERE sessionId = s.id) AS bindingCount,
|
||||||
|
(SELECT content FROM unified_messages WHERE sessionId = s.id ORDER BY createdAt DESC, id DESC LIMIT 1) AS lastMessagePreview
|
||||||
|
FROM unified_sessions s
|
||||||
|
ORDER BY s.updatedAt DESC
|
||||||
|
""")
|
||||||
|
suspend fun getUnifiedSessionsSummary(): List<UnifiedSessionSummaryProjection>
|
||||||
|
|
||||||
@Query("SELECT * FROM unified_sessions WHERE id = :sessionId LIMIT 1")
|
@Query("SELECT * FROM unified_sessions WHERE id = :sessionId LIMIT 1")
|
||||||
suspend fun getSession(sessionId: String): UnifiedSessionEntity?
|
suspend fun getSession(sessionId: String): UnifiedSessionEntity?
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -90,3 +90,14 @@ data class UnifiedSessionWithDetails(
|
||||||
val bindings: List<HostBindingEntity> = emptyList(),
|
val bindings: List<HostBindingEntity> = emptyList(),
|
||||||
val messages: List<UnifiedMessageEntity> = emptyList()
|
val messages: List<UnifiedMessageEntity> = emptyList()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class UnifiedSessionSummaryProjection(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val activeHostId: String,
|
||||||
|
val createdAt: Long = 0L,
|
||||||
|
val updatedAt: Long = 0L,
|
||||||
|
val messageCount: Int = 0,
|
||||||
|
val bindingCount: Int = 0,
|
||||||
|
val lastMessagePreview: String? = null
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,8 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import kotlinx.coroutines.flow.conflate
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
|
@ -111,10 +113,28 @@ fun ChatScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(messages.size, messages.lastOrNull()?.content?.length, approvals.size) {
|
LaunchedEffect(listState) {
|
||||||
if (messages.isNotEmpty() || approvals.isNotEmpty()) {
|
snapshotFlow {
|
||||||
val totalCount = messages.size + approvals.size
|
val totalItems = messages.size + approvals.size
|
||||||
listState.animateScrollToItem(totalCount)
|
val isStreaming = isExecuting || messages.any { it.isStreaming }
|
||||||
|
val isAtBottom = !listState.canScrollForward || (listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1) >= (listState.layoutInfo.totalItemsCount - 2)
|
||||||
|
val lastMessageLength = messages.lastOrNull()?.content?.length ?: 0
|
||||||
|
listOf(totalItems, isStreaming, isAtBottom, lastMessageLength)
|
||||||
|
}
|
||||||
|
.conflate()
|
||||||
|
.collect { stateList ->
|
||||||
|
val totalItems = stateList[0] as Int
|
||||||
|
val isStreaming = stateList[1] as Boolean
|
||||||
|
val isAtBottom = stateList[2] as Boolean
|
||||||
|
|
||||||
|
if (totalItems > 0 && isAtBottom) {
|
||||||
|
val targetIndex = maxOf(0, totalItems - 1)
|
||||||
|
if (isStreaming) {
|
||||||
|
listState.scrollToItem(targetIndex)
|
||||||
|
} else {
|
||||||
|
listState.animateScrollToItem(targetIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -143,4 +143,9 @@ class ChatViewModel(
|
||||||
sessionRepo.dismissClarify(hostId, req.requestId, req.promptType, req.questionId)
|
sessionRepo.dismissClarify(hostId, req.requestId, req.promptType, req.questionId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
super.onCleared()
|
||||||
|
sessionRepo.releaseSession(sessionId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -285,8 +285,9 @@ fun UnifiedSessionCard(
|
||||||
|
|
||||||
// Attached hosts counter
|
// Attached hosts counter
|
||||||
val attachedCount = session.bindings.size.coerceAtLeast(1)
|
val attachedCount = session.bindings.size.coerceAtLeast(1)
|
||||||
|
val msgCount = if (session.messageCount > 0) session.messageCount else session.timeline.size
|
||||||
Text(
|
Text(
|
||||||
text = "$attachedCount attached • ${session.timeline.size} msgs",
|
text = "$attachedCount attached • $msgCount msgs",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
package app.hermes.mobile.core.repository
|
||||||
|
|
||||||
|
import app.hermes.mobile.core.model.*
|
||||||
|
import app.hermes.mobile.core.network.JsonRpcGatewayClient
|
||||||
|
import app.hermes.mobile.core.runtime.HermesConnectionManager
|
||||||
|
import app.hermes.mobile.core.runtime.HermesHostRuntime
|
||||||
|
import app.hermes.mobile.core.security.InMemoryTokenVault
|
||||||
|
import app.hermes.mobile.core.storage.FakeHostDao
|
||||||
|
import app.hermes.mobile.core.storage.FakeUnifiedSessionDao
|
||||||
|
import app.hermes.mobile.core.storage.HostBindingEntity
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies that opening, streaming in, and closing/unsubscribing from 50 sessions
|
||||||
|
* does not leak memory in repository caches (sessionMessagesState, hostExecutingState,
|
||||||
|
* sessionExecutingState, sessionHostMutexes, toolIdToMessageId).
|
||||||
|
*/
|
||||||
|
class CacheEvictionTest {
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun getInternalMapSize(repository: UnifiedSessionRepository, fieldName: String): Int {
|
||||||
|
return try {
|
||||||
|
val field = UnifiedSessionRepository::class.java.getDeclaredField(fieldName)
|
||||||
|
field.isAccessible = true
|
||||||
|
val map = field.get(repository) as? Map<*, *>
|
||||||
|
map?.size ?: 0
|
||||||
|
} catch (_: NoSuchFieldException) {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test50SessionsCycleEvictsAndBoundsMemoryCaches() = runBlocking {
|
||||||
|
val hostId = HermesHostId("host-cache-1")
|
||||||
|
val host = HermesHost(id = hostId, displayName = "Cache Host", baseUrl = "http://cache-host:9119")
|
||||||
|
|
||||||
|
val hostDao = FakeHostDao()
|
||||||
|
val sessionDao = FakeUnifiedSessionDao()
|
||||||
|
val tokenVault = InMemoryTokenVault()
|
||||||
|
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
|
val connectionManager = HermesConnectionManager(
|
||||||
|
hostDao = hostDao,
|
||||||
|
tokenVault = tokenVault,
|
||||||
|
scope = scope,
|
||||||
|
runtimeFactory = { parentScope, h ->
|
||||||
|
val childScope = CoroutineScope(SupervisorJob(parentScope.coroutineContext[Job]) + Dispatchers.Default)
|
||||||
|
HermesHostRuntime(
|
||||||
|
initialHost = h,
|
||||||
|
gatewayClient = JsonRpcGatewayClient(scope = childScope),
|
||||||
|
tokenVault = tokenVault,
|
||||||
|
scope = childScope
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
val repository = UnifiedSessionRepository(
|
||||||
|
connectionManager = connectionManager,
|
||||||
|
sessionDao = sessionDao,
|
||||||
|
scope = scope
|
||||||
|
)
|
||||||
|
|
||||||
|
connectionManager.addHost(host)
|
||||||
|
delay(50)
|
||||||
|
val runtime = connectionManager.getRuntime(hostId)!!
|
||||||
|
|
||||||
|
val sessionCount = 50
|
||||||
|
|
||||||
|
// Cycle through 50 sessions
|
||||||
|
for (i in 1..sessionCount) {
|
||||||
|
val session = repository.createUnifiedSession(title = "Session $i", initialHostId = hostId)
|
||||||
|
val rtSessionId = "rt_session_$i"
|
||||||
|
val msgId = "msg_$i"
|
||||||
|
|
||||||
|
repository.registerRuntimeBinding(session.id, hostId, RuntimeSessionId(rtSessionId))
|
||||||
|
sessionDao.insertOrUpdateBinding(
|
||||||
|
HostBindingEntity(
|
||||||
|
sessionId = session.id.value,
|
||||||
|
hostId = hostId.value,
|
||||||
|
durableSessionId = "dur_$i",
|
||||||
|
runtimeSessionId = rtSessionId,
|
||||||
|
state = BindingState.RUNNING.name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Simulate subscription to session flows
|
||||||
|
val messagesFlow = repository.getSessionMessages(session.id)
|
||||||
|
val execFlow = repository.getSessionExecuting(session.id)
|
||||||
|
val hostExecFlow = repository.getHostExecuting(session.id, hostId)
|
||||||
|
|
||||||
|
val job = launch {
|
||||||
|
messagesFlow.collect {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream a message with tool
|
||||||
|
val startJson = buildJsonObject {
|
||||||
|
put("jsonrpc", "2.0")
|
||||||
|
put("method", "event")
|
||||||
|
put("params", buildJsonObject {
|
||||||
|
put("type", "message.start")
|
||||||
|
put("session_id", rtSessionId)
|
||||||
|
put("payload", buildJsonObject {
|
||||||
|
put("message_id", msgId)
|
||||||
|
put("role", "assistant")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}.toString()
|
||||||
|
runtime.gatewayClient.handleIncomingMessage(startJson)
|
||||||
|
|
||||||
|
val toolStartJson = buildJsonObject {
|
||||||
|
put("jsonrpc", "2.0")
|
||||||
|
put("method", "event")
|
||||||
|
put("params", buildJsonObject {
|
||||||
|
put("type", "tool.start")
|
||||||
|
put("session_id", rtSessionId)
|
||||||
|
put("payload", buildJsonObject {
|
||||||
|
put("tool_id", "tool_$i")
|
||||||
|
put("name", "test_tool")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}.toString()
|
||||||
|
runtime.gatewayClient.handleIncomingMessage(toolStartJson)
|
||||||
|
|
||||||
|
val completeJson = buildJsonObject {
|
||||||
|
put("jsonrpc", "2.0")
|
||||||
|
put("method", "event")
|
||||||
|
put("params", buildJsonObject {
|
||||||
|
put("type", "message.complete")
|
||||||
|
put("session_id", rtSessionId)
|
||||||
|
put("payload", buildJsonObject {
|
||||||
|
put("message_id", msgId)
|
||||||
|
put("content", "Done $i")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}.toString()
|
||||||
|
runtime.gatewayClient.handleIncomingMessage(completeJson)
|
||||||
|
delay(20)
|
||||||
|
|
||||||
|
// Unsubscribe
|
||||||
|
job.cancel()
|
||||||
|
|
||||||
|
// Release session explicitly if method exists
|
||||||
|
try {
|
||||||
|
val releaseMethod = repository.javaClass.getMethod("releaseSession", UnifiedSessionId::class.java)
|
||||||
|
releaseMethod.invoke(repository, session.id)
|
||||||
|
} catch (_: NoSuchMethodException) {
|
||||||
|
// Base SHA does not have releaseSession
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delay(100)
|
||||||
|
|
||||||
|
val messagesCacheSize = getInternalMapSize(repository, "sessionMessagesState")
|
||||||
|
val sessionExecSize = getInternalMapSize(repository, "sessionExecutingState")
|
||||||
|
val hostExecSize = getInternalMapSize(repository, "hostExecutingState")
|
||||||
|
val mutexesSize = getInternalMapSize(repository, "sessionHostMutexes")
|
||||||
|
val toolMapSize = getInternalMapSize(repository, "toolToMessageMap").coerceAtLeast(
|
||||||
|
getInternalMapSize(repository, "toolIdToMessageId")
|
||||||
|
)
|
||||||
|
|
||||||
|
println("BASELINE MEASUREMENT [Cache sizes after 50 sessions]: messagesCache=$messagesCacheSize, sessionExec=$sessionExecSize, hostExec=$hostExecSize, mutexes=$mutexesSize, toolMap=$toolMapSize")
|
||||||
|
|
||||||
|
// Assert caches are pruned / bounded (bounded to at most small LRU size e.g. <= 5 or 0 when released)
|
||||||
|
assertTrue("sessionMessagesState ($messagesCacheSize) must be bounded <= 5", messagesCacheSize <= 5)
|
||||||
|
assertTrue("sessionExecutingState ($sessionExecSize) must be bounded <= 5", sessionExecSize <= 5)
|
||||||
|
assertTrue("hostExecutingState ($hostExecSize) must be bounded <= 5", hostExecSize <= 5)
|
||||||
|
assertTrue("sessionHostMutexes ($mutexesSize) must be bounded <= 5", mutexesSize <= 5)
|
||||||
|
assertTrue("toolToMessageMap ($toolMapSize) must be pruned upon completion", toolMapSize <= 5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
package app.hermes.mobile.core.repository
|
||||||
|
|
||||||
|
import app.hermes.mobile.core.model.HermesHost
|
||||||
|
import app.hermes.mobile.core.model.HermesHostId
|
||||||
|
import app.hermes.mobile.core.runtime.HermesConnectionManager
|
||||||
|
import app.hermes.mobile.core.security.InMemoryTokenVault
|
||||||
|
import app.hermes.mobile.core.storage.FakeHostDao
|
||||||
|
import app.hermes.mobile.core.storage.FakeUnifiedSessionDao
|
||||||
|
import app.hermes.mobile.core.storage.HostBindingEntity
|
||||||
|
import app.hermes.mobile.core.storage.UnifiedMessageEntity
|
||||||
|
import app.hermes.mobile.core.storage.UnifiedSessionDao
|
||||||
|
import app.hermes.mobile.core.storage.UnifiedSessionEntity
|
||||||
|
import app.hermes.mobile.core.storage.UnifiedSessionWithDetails
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies that loading the list of 100 sessions does not perform N+1 database queries.
|
||||||
|
* On base SHA, collecting sessions flow causes 100+ queries (1 getSessionsFlow + 100 getSessionWithDetails).
|
||||||
|
*/
|
||||||
|
class SessionListQueryCountTest {
|
||||||
|
|
||||||
|
private class TrackingUnifiedSessionDao(
|
||||||
|
private val delegate: FakeUnifiedSessionDao
|
||||||
|
) : UnifiedSessionDao by delegate {
|
||||||
|
val queryCount = AtomicInteger(0)
|
||||||
|
val detailsQueryCount = AtomicInteger(0)
|
||||||
|
|
||||||
|
override fun getSessionsFlow(): Flow<List<UnifiedSessionEntity>> {
|
||||||
|
queryCount.incrementAndGet()
|
||||||
|
return delegate.getSessionsFlow()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getSessions(): List<UnifiedSessionEntity> {
|
||||||
|
queryCount.incrementAndGet()
|
||||||
|
return delegate.getSessions()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getSession(sessionId: String): UnifiedSessionEntity? {
|
||||||
|
queryCount.incrementAndGet()
|
||||||
|
return delegate.getSession(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getSessionWithDetails(sessionId: String): UnifiedSessionWithDetails? {
|
||||||
|
queryCount.incrementAndGet()
|
||||||
|
detailsQueryCount.incrementAndGet()
|
||||||
|
return delegate.getSessionWithDetails(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getUnifiedSessionsSummaryFlow(): Flow<List<app.hermes.mobile.core.storage.UnifiedSessionSummaryProjection>> {
|
||||||
|
queryCount.incrementAndGet()
|
||||||
|
return delegate.getUnifiedSessionsSummaryFlow()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getUnifiedSessionsSummary(): List<app.hermes.mobile.core.storage.UnifiedSessionSummaryProjection> {
|
||||||
|
queryCount.incrementAndGet()
|
||||||
|
return delegate.getUnifiedSessionsSummary()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getMessagesForSession(sessionId: String): List<UnifiedMessageEntity> {
|
||||||
|
queryCount.incrementAndGet()
|
||||||
|
return delegate.getMessagesForSession(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getBindingsForSession(sessionId: String): List<HostBindingEntity> {
|
||||||
|
queryCount.incrementAndGet()
|
||||||
|
return delegate.getBindingsForSession(sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test100SessionsListQueryCountIsFixedAndNotNPlusOne() = runBlocking {
|
||||||
|
val hostDao = FakeHostDao()
|
||||||
|
val rawSessionDao = FakeUnifiedSessionDao()
|
||||||
|
val trackingDao = TrackingUnifiedSessionDao(rawSessionDao)
|
||||||
|
val tokenVault = InMemoryTokenVault()
|
||||||
|
val scope = CoroutineScope(Dispatchers.Default)
|
||||||
|
|
||||||
|
val hostId = HermesHostId("host-perf-1")
|
||||||
|
val host = HermesHost(id = hostId, displayName = "Host Perf", baseUrl = "http://host-perf:9119")
|
||||||
|
val connectionManager = HermesConnectionManager(
|
||||||
|
hostDao = hostDao,
|
||||||
|
tokenVault = tokenVault,
|
||||||
|
scope = scope
|
||||||
|
)
|
||||||
|
connectionManager.addHost(host)
|
||||||
|
|
||||||
|
val sessionCount = 100
|
||||||
|
val messagesPerSession = 20
|
||||||
|
|
||||||
|
// Populate 100 sessions with 20 messages each
|
||||||
|
for (i in 1..sessionCount) {
|
||||||
|
val sid = "session-$i"
|
||||||
|
rawSessionDao.insertSession(
|
||||||
|
UnifiedSessionEntity(
|
||||||
|
id = sid,
|
||||||
|
title = "Session $i",
|
||||||
|
activeHostId = hostId.value,
|
||||||
|
createdAt = 1000L + i,
|
||||||
|
updatedAt = 1000L + i
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val msgs = (1..messagesPerSession).map { mIdx ->
|
||||||
|
UnifiedMessageEntity(
|
||||||
|
id = "msg-$i-$mIdx",
|
||||||
|
sessionId = sid,
|
||||||
|
role = if (mIdx % 2 == 0) "ASSISTANT" else "USER",
|
||||||
|
content = "Content $i - $mIdx",
|
||||||
|
hostId = hostId.value,
|
||||||
|
createdAt = 1000L + i * 100 + mIdx
|
||||||
|
)
|
||||||
|
}
|
||||||
|
rawSessionDao.insertMessages(msgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset tracking counters before initializing repository & collecting sessions
|
||||||
|
trackingDao.queryCount.set(0)
|
||||||
|
trackingDao.detailsQueryCount.set(0)
|
||||||
|
|
||||||
|
val repository = UnifiedSessionRepository(
|
||||||
|
connectionManager = connectionManager,
|
||||||
|
sessionDao = trackingDao,
|
||||||
|
scope = scope
|
||||||
|
)
|
||||||
|
|
||||||
|
// Read the sessions flow
|
||||||
|
val sessionsList = repository.sessions.first { it.size == sessionCount }
|
||||||
|
assertEquals(sessionCount, sessionsList.size)
|
||||||
|
|
||||||
|
val totalQueries = trackingDao.queryCount.get()
|
||||||
|
val detailsQueries = trackingDao.detailsQueryCount.get()
|
||||||
|
|
||||||
|
println("BASELINE MEASUREMENT [100 Sessions List]: totalQueries=$totalQueries, detailsQueries=$detailsQueries")
|
||||||
|
|
||||||
|
// In a lightweight projection, detailsQueries must be 0 and totalQueries must be <= 2 (fixed count, O(1))
|
||||||
|
assertEquals("Should not invoke getSessionWithDetails in N+1 loop", 0, detailsQueries)
|
||||||
|
assertTrue("Total queries ($totalQueries) must be <= 2, not O(N)", totalQueries <= 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,385 @@
|
||||||
|
package app.hermes.mobile.core.repository
|
||||||
|
|
||||||
|
import app.hermes.mobile.core.model.*
|
||||||
|
import app.hermes.mobile.core.network.JsonRpcGatewayClient
|
||||||
|
import app.hermes.mobile.core.runtime.HermesConnectionManager
|
||||||
|
import app.hermes.mobile.core.runtime.HermesHostRuntime
|
||||||
|
import app.hermes.mobile.core.security.InMemoryTokenVault
|
||||||
|
import app.hermes.mobile.core.storage.FakeHostDao
|
||||||
|
import app.hermes.mobile.core.storage.FakeUnifiedSessionDao
|
||||||
|
import app.hermes.mobile.core.storage.HostBindingEntity
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies that when 2 hosts stream concurrently into the same session with tool events
|
||||||
|
* and thinking deltas, tools and thinking are strictly bound to their respective host and
|
||||||
|
* explicit messageId, avoiding false attribution.
|
||||||
|
*/
|
||||||
|
class ToolAttributionTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testTwoConcurrentHostsAttributionAndThinkingIsolation() = runBlocking {
|
||||||
|
val hostAId = HermesHostId("host-a")
|
||||||
|
val hostBId = HermesHostId("host-b")
|
||||||
|
|
||||||
|
val hostA = HermesHost(id = hostAId, displayName = "Host A", baseUrl = "http://host-a:9119")
|
||||||
|
val hostB = HermesHost(id = hostBId, displayName = "Host B", baseUrl = "http://host-b:9119")
|
||||||
|
|
||||||
|
val hostDao = FakeHostDao()
|
||||||
|
val sessionDao = FakeUnifiedSessionDao()
|
||||||
|
val tokenVault = InMemoryTokenVault()
|
||||||
|
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
|
val connectionManager = HermesConnectionManager(
|
||||||
|
hostDao = hostDao,
|
||||||
|
tokenVault = tokenVault,
|
||||||
|
scope = scope,
|
||||||
|
runtimeFactory = { parentScope, h ->
|
||||||
|
val childScope = CoroutineScope(SupervisorJob(parentScope.coroutineContext[Job]) + Dispatchers.Default)
|
||||||
|
HermesHostRuntime(
|
||||||
|
initialHost = h,
|
||||||
|
gatewayClient = JsonRpcGatewayClient(scope = childScope),
|
||||||
|
tokenVault = tokenVault,
|
||||||
|
scope = childScope
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
val repository = UnifiedSessionRepository(
|
||||||
|
connectionManager = connectionManager,
|
||||||
|
sessionDao = sessionDao,
|
||||||
|
scope = scope
|
||||||
|
)
|
||||||
|
|
||||||
|
connectionManager.addHost(hostA)
|
||||||
|
connectionManager.addHost(hostB)
|
||||||
|
delay(50)
|
||||||
|
|
||||||
|
val session = repository.createUnifiedSession(title = "Dual Host Attribution Test", initialHostId = hostAId)
|
||||||
|
val rtSessionA = "rt_session_host_a"
|
||||||
|
val rtSessionB = "rt_session_host_b"
|
||||||
|
|
||||||
|
repository.registerRuntimeBinding(session.id, hostAId, RuntimeSessionId(rtSessionA))
|
||||||
|
repository.registerRuntimeBinding(session.id, hostBId, RuntimeSessionId(rtSessionB))
|
||||||
|
|
||||||
|
sessionDao.insertOrUpdateBinding(
|
||||||
|
HostBindingEntity(
|
||||||
|
sessionId = session.id.value,
|
||||||
|
hostId = hostAId.value,
|
||||||
|
durableSessionId = "dur_a",
|
||||||
|
runtimeSessionId = rtSessionA,
|
||||||
|
state = BindingState.RUNNING.name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
sessionDao.insertOrUpdateBinding(
|
||||||
|
HostBindingEntity(
|
||||||
|
sessionId = session.id.value,
|
||||||
|
hostId = hostBId.value,
|
||||||
|
durableSessionId = "dur_b",
|
||||||
|
runtimeSessionId = rtSessionB,
|
||||||
|
state = BindingState.RUNNING.name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val runtimeA = connectionManager.getRuntime(hostAId)!!
|
||||||
|
val runtimeB = connectionManager.getRuntime(hostBId)!!
|
||||||
|
|
||||||
|
val msgAId = "msg_host_a_1"
|
||||||
|
val msgBId = "msg_host_b_1"
|
||||||
|
|
||||||
|
// 1. Host A starts message
|
||||||
|
runtimeA.gatewayClient.handleIncomingMessage(buildJsonObject {
|
||||||
|
put("jsonrpc", "2.0")
|
||||||
|
put("method", "event")
|
||||||
|
put("params", buildJsonObject {
|
||||||
|
put("type", "message.start")
|
||||||
|
put("session_id", rtSessionA)
|
||||||
|
put("payload", buildJsonObject {
|
||||||
|
put("message_id", msgAId)
|
||||||
|
put("role", "assistant")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}.toString())
|
||||||
|
|
||||||
|
// 2. Host B starts message
|
||||||
|
runtimeB.gatewayClient.handleIncomingMessage(buildJsonObject {
|
||||||
|
put("jsonrpc", "2.0")
|
||||||
|
put("method", "event")
|
||||||
|
put("params", buildJsonObject {
|
||||||
|
put("type", "message.start")
|
||||||
|
put("session_id", rtSessionB)
|
||||||
|
put("payload", buildJsonObject {
|
||||||
|
put("message_id", msgBId)
|
||||||
|
put("role", "assistant")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}.toString())
|
||||||
|
|
||||||
|
delay(30)
|
||||||
|
|
||||||
|
// 3. Host A streams thinking delta for msgA
|
||||||
|
runtimeA.gatewayClient.handleIncomingMessage(buildJsonObject {
|
||||||
|
put("jsonrpc", "2.0")
|
||||||
|
put("method", "event")
|
||||||
|
put("params", buildJsonObject {
|
||||||
|
put("type", "thinking.delta")
|
||||||
|
put("session_id", rtSessionA)
|
||||||
|
put("payload", buildJsonObject {
|
||||||
|
put("message_id", msgAId)
|
||||||
|
put("delta", "Plan on Host A")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}.toString())
|
||||||
|
|
||||||
|
// 4. Host B streams thinking delta for msgB
|
||||||
|
runtimeB.gatewayClient.handleIncomingMessage(buildJsonObject {
|
||||||
|
put("jsonrpc", "2.0")
|
||||||
|
put("method", "event")
|
||||||
|
put("params", buildJsonObject {
|
||||||
|
put("type", "thinking.delta")
|
||||||
|
put("session_id", rtSessionB)
|
||||||
|
put("payload", buildJsonObject {
|
||||||
|
put("message_id", msgBId)
|
||||||
|
put("delta", "Plan on Host B")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}.toString())
|
||||||
|
|
||||||
|
// 5. Host A starts tool
|
||||||
|
val toolAId = "tool_host_a_1"
|
||||||
|
runtimeA.gatewayClient.handleIncomingMessage(buildJsonObject {
|
||||||
|
put("jsonrpc", "2.0")
|
||||||
|
put("method", "event")
|
||||||
|
put("params", buildJsonObject {
|
||||||
|
put("type", "tool.start")
|
||||||
|
put("session_id", rtSessionA)
|
||||||
|
put("payload", buildJsonObject {
|
||||||
|
put("tool_id", toolAId)
|
||||||
|
put("name", "bash_executor")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}.toString())
|
||||||
|
|
||||||
|
// 6. Host B starts tool
|
||||||
|
val toolBId = "tool_host_b_1"
|
||||||
|
runtimeB.gatewayClient.handleIncomingMessage(buildJsonObject {
|
||||||
|
put("jsonrpc", "2.0")
|
||||||
|
put("method", "event")
|
||||||
|
put("params", buildJsonObject {
|
||||||
|
put("type", "tool.start")
|
||||||
|
put("session_id", rtSessionB)
|
||||||
|
put("payload", buildJsonObject {
|
||||||
|
put("tool_id", toolBId)
|
||||||
|
put("name", "file_editor")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}.toString())
|
||||||
|
|
||||||
|
delay(30)
|
||||||
|
|
||||||
|
// 7. Update tool progress and completion for host A
|
||||||
|
runtimeA.gatewayClient.handleIncomingMessage(buildJsonObject {
|
||||||
|
put("jsonrpc", "2.0")
|
||||||
|
put("method", "event")
|
||||||
|
put("params", buildJsonObject {
|
||||||
|
put("type", "tool.complete")
|
||||||
|
put("session_id", rtSessionA)
|
||||||
|
put("payload", buildJsonObject {
|
||||||
|
put("tool_id", toolAId)
|
||||||
|
put("result", "Output from Host A")
|
||||||
|
put("is_error", false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}.toString())
|
||||||
|
|
||||||
|
// 8. Update tool progress and completion for host B
|
||||||
|
runtimeB.gatewayClient.handleIncomingMessage(buildJsonObject {
|
||||||
|
put("jsonrpc", "2.0")
|
||||||
|
put("method", "event")
|
||||||
|
put("params", buildJsonObject {
|
||||||
|
put("type", "tool.complete")
|
||||||
|
put("session_id", rtSessionB)
|
||||||
|
put("payload", buildJsonObject {
|
||||||
|
put("tool_id", toolBId)
|
||||||
|
put("result", "Output from Host B")
|
||||||
|
put("is_error", false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}.toString())
|
||||||
|
|
||||||
|
// 9. Complete messages
|
||||||
|
runtimeA.gatewayClient.handleIncomingMessage(buildJsonObject {
|
||||||
|
put("jsonrpc", "2.0")
|
||||||
|
put("method", "event")
|
||||||
|
put("params", buildJsonObject {
|
||||||
|
put("type", "message.complete")
|
||||||
|
put("session_id", rtSessionA)
|
||||||
|
put("payload", buildJsonObject {
|
||||||
|
put("message_id", msgAId)
|
||||||
|
put("content", "Final content A")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}.toString())
|
||||||
|
|
||||||
|
runtimeB.gatewayClient.handleIncomingMessage(buildJsonObject {
|
||||||
|
put("jsonrpc", "2.0")
|
||||||
|
put("method", "event")
|
||||||
|
put("params", buildJsonObject {
|
||||||
|
put("type", "message.complete")
|
||||||
|
put("session_id", rtSessionB)
|
||||||
|
put("payload", buildJsonObject {
|
||||||
|
put("message_id", msgBId)
|
||||||
|
put("content", "Final content B")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}.toString())
|
||||||
|
|
||||||
|
delay(50)
|
||||||
|
|
||||||
|
val messages = repository.getSessionMessages(session.id).value
|
||||||
|
val msgA = messages.find { it.id == msgAId }
|
||||||
|
val msgB = messages.find { it.id == msgBId }
|
||||||
|
|
||||||
|
assertNotNull("Message A must exist", msgA)
|
||||||
|
assertNotNull("Message B must exist", msgB)
|
||||||
|
|
||||||
|
assertEquals("Host A attribution", hostAId, msgA?.hostId)
|
||||||
|
assertEquals("Host B attribution", hostBId, msgB?.hostId)
|
||||||
|
|
||||||
|
assertEquals("Thinking for Message A", "Plan on Host A", msgA?.thinking)
|
||||||
|
assertEquals("Thinking for Message B", "Plan on Host B", msgB?.thinking)
|
||||||
|
|
||||||
|
assertEquals("Tools count for Message A", 1, msgA?.tools?.size)
|
||||||
|
assertEquals("Tool ID for Message A", toolAId, msgA?.tools?.firstOrNull()?.id)
|
||||||
|
assertEquals("Tool result for Message A", "Output from Host A", msgA?.tools?.firstOrNull()?.result)
|
||||||
|
|
||||||
|
assertEquals("Tools count for Message B", 1, msgB?.tools?.size)
|
||||||
|
assertEquals("Tool ID for Message B", toolBId, msgB?.tools?.firstOrNull()?.id)
|
||||||
|
assertEquals("Tool result for Message B", "Output from Host B", msgB?.tools?.firstOrNull()?.result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testThinkingDeltaWithExplicitMessageIdDoesNotFallBackToLastAssistant() = runBlocking {
|
||||||
|
val hostAId = HermesHostId("host-a")
|
||||||
|
val hostA = HermesHost(id = hostAId, displayName = "Host A", baseUrl = "http://host-a:9119")
|
||||||
|
|
||||||
|
val hostDao = FakeHostDao()
|
||||||
|
val sessionDao = FakeUnifiedSessionDao()
|
||||||
|
val tokenVault = InMemoryTokenVault()
|
||||||
|
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
|
val connectionManager = HermesConnectionManager(
|
||||||
|
hostDao = hostDao,
|
||||||
|
tokenVault = tokenVault,
|
||||||
|
scope = scope,
|
||||||
|
runtimeFactory = { parentScope, h ->
|
||||||
|
val childScope = CoroutineScope(SupervisorJob(parentScope.coroutineContext[Job]) + Dispatchers.Default)
|
||||||
|
HermesHostRuntime(
|
||||||
|
initialHost = h,
|
||||||
|
gatewayClient = JsonRpcGatewayClient(scope = childScope),
|
||||||
|
tokenVault = tokenVault,
|
||||||
|
scope = childScope
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
val repository = UnifiedSessionRepository(
|
||||||
|
connectionManager = connectionManager,
|
||||||
|
sessionDao = sessionDao,
|
||||||
|
scope = scope
|
||||||
|
)
|
||||||
|
|
||||||
|
connectionManager.addHost(hostA)
|
||||||
|
delay(50)
|
||||||
|
|
||||||
|
val session = repository.createUnifiedSession(title = "Message Targeting Test", initialHostId = hostAId)
|
||||||
|
val rtSessionA = "rt_session_host_a"
|
||||||
|
|
||||||
|
repository.registerRuntimeBinding(session.id, hostAId, RuntimeSessionId(rtSessionA))
|
||||||
|
sessionDao.insertOrUpdateBinding(
|
||||||
|
HostBindingEntity(
|
||||||
|
sessionId = session.id.value,
|
||||||
|
hostId = hostAId.value,
|
||||||
|
durableSessionId = "dur_a",
|
||||||
|
runtimeSessionId = rtSessionA,
|
||||||
|
state = BindingState.RUNNING.name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val runtimeA = connectionManager.getRuntime(hostAId)!!
|
||||||
|
|
||||||
|
val msg1Id = "msg_first_1"
|
||||||
|
val msg2Id = "msg_second_2"
|
||||||
|
|
||||||
|
// Host A starts first assistant message
|
||||||
|
runtimeA.gatewayClient.handleIncomingMessage(buildJsonObject {
|
||||||
|
put("jsonrpc", "2.0")
|
||||||
|
put("method", "event")
|
||||||
|
put("params", buildJsonObject {
|
||||||
|
put("type", "message.start")
|
||||||
|
put("session_id", rtSessionA)
|
||||||
|
put("payload", buildJsonObject {
|
||||||
|
put("message_id", msg1Id)
|
||||||
|
put("role", "assistant")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}.toString())
|
||||||
|
|
||||||
|
// Host A starts second assistant message
|
||||||
|
runtimeA.gatewayClient.handleIncomingMessage(buildJsonObject {
|
||||||
|
put("jsonrpc", "2.0")
|
||||||
|
put("method", "event")
|
||||||
|
put("params", buildJsonObject {
|
||||||
|
put("type", "message.start")
|
||||||
|
put("session_id", rtSessionA)
|
||||||
|
put("payload", buildJsonObject {
|
||||||
|
put("message_id", msg2Id)
|
||||||
|
put("role", "assistant")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}.toString())
|
||||||
|
|
||||||
|
delay(30)
|
||||||
|
|
||||||
|
// Send thinking delta explicitly targeted at msg1Id
|
||||||
|
runtimeA.gatewayClient.handleIncomingMessage(buildJsonObject {
|
||||||
|
put("jsonrpc", "2.0")
|
||||||
|
put("method", "event")
|
||||||
|
put("params", buildJsonObject {
|
||||||
|
put("type", "thinking.delta")
|
||||||
|
put("session_id", rtSessionA)
|
||||||
|
put("payload", buildJsonObject {
|
||||||
|
put("message_id", msg1Id)
|
||||||
|
put("delta", "Thinking targeted exclusively at msg1")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}.toString())
|
||||||
|
|
||||||
|
delay(30)
|
||||||
|
|
||||||
|
val messages = repository.getSessionMessages(session.id).value
|
||||||
|
val msg1 = messages.find { it.id == msg1Id }
|
||||||
|
val msg2 = messages.find { it.id == msg2Id }
|
||||||
|
|
||||||
|
assertNotNull("msg1 must exist", msg1)
|
||||||
|
assertNotNull("msg2 must exist", msg2)
|
||||||
|
|
||||||
|
// On base SHA: targetAssistant is lastOrNull { (it.id == msg1Id || it.role == ASSISTANT) }
|
||||||
|
// Because msg2 has role == ASSISTANT and is last, it matches msg2!
|
||||||
|
// So msg1 thinking will be null and msg2 thinking will have the text on base SHA!
|
||||||
|
assertEquals("msg1 must receive thinking targeted at it", "Thinking targeted exclusively at msg1", msg1?.thinking)
|
||||||
|
assertEquals("msg2 must NOT receive thinking intended for msg1", null, msg2?.thinking)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -88,6 +88,28 @@ class FakeUnifiedSessionDao : UnifiedSessionDao {
|
||||||
sessions.values.sortedByDescending { it.updatedAt }.map { it.copy() }
|
sessions.values.sortedByDescending { it.updatedAt }.map { it.copy() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun getUnifiedSessionsSummaryFlow(): Flow<List<UnifiedSessionSummaryProjection>> =
|
||||||
|
_sessionsFlow.map { getUnifiedSessionsSummary() }
|
||||||
|
|
||||||
|
override suspend fun getUnifiedSessionsSummary(): List<UnifiedSessionSummaryProjection> = synchronized(lock) {
|
||||||
|
sessions.values.sortedByDescending { it.updatedAt }.map { s ->
|
||||||
|
val msgList = messages[s.id]
|
||||||
|
val lastMsg = msgList?.sortedWith(messageComparator)?.lastOrNull()?.content
|
||||||
|
val msgCount = msgList?.size ?: 0
|
||||||
|
val bindCount = bindings[s.id]?.size ?: 0
|
||||||
|
UnifiedSessionSummaryProjection(
|
||||||
|
id = s.id,
|
||||||
|
title = s.title,
|
||||||
|
activeHostId = s.activeHostId,
|
||||||
|
createdAt = s.createdAt,
|
||||||
|
updatedAt = s.updatedAt,
|
||||||
|
messageCount = msgCount,
|
||||||
|
bindingCount = bindCount,
|
||||||
|
lastMessagePreview = lastMsg
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getSession(sessionId: String): UnifiedSessionEntity? = synchronized(lock) {
|
override suspend fun getSession(sessionId: String): UnifiedSessionEntity? = synchronized(lock) {
|
||||||
sessions[sessionId]?.copy()
|
sessions[sessionId]?.copy()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,6 @@ class MessageOrderingTest {
|
||||||
assertEquals("Repository getUnifiedSession must order messages by createdAt ASC, id ASC", expected, timelineIds)
|
assertEquals("Repository getUnifiedSession must order messages by createdAt ASC, id ASC", expected, timelineIds)
|
||||||
|
|
||||||
val sessionFromList = repository.sessions.value.find { it.id == session.id }
|
val sessionFromList = repository.sessions.value.find { it.id == session.id }
|
||||||
val sessionListTimelineIds = sessionFromList?.timeline?.map { it.id }
|
assertEquals("Repository sessions flow projection must report message count", 3, sessionFromList?.messageCount)
|
||||||
assertEquals("Repository sessions flow must order messages by createdAt ASC, id ASC", expected, sessionListTimelineIds)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue