diff --git a/app/schemas/com.m57.hermescontrol.data.local.HermesDatabase/4.json b/app/schemas/com.m57.hermescontrol.data.local.HermesDatabase/4.json new file mode 100644 index 00000000..ae593808 --- /dev/null +++ b/app/schemas/com.m57.hermescontrol.data.local.HermesDatabase/4.json @@ -0,0 +1,89 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "7837ef04f86f9b8fca7a52a62b96f610", + "entities": [ + { + "tableName": "chat_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `session_id` TEXT NOT NULL, `role` TEXT NOT NULL, `content` TEXT NOT NULL, `reasoning_text` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `tool_name` TEXT, `tool_status` TEXT, `is_streaming` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "session_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reasoningText", + "columnName": "reasoning_text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toolName", + "columnName": "tool_name", + "affinity": "TEXT" + }, + { + "fieldPath": "toolStatus", + "columnName": "tool_status", + "affinity": "TEXT" + }, + { + "fieldPath": "isStreaming", + "columnName": "is_streaming", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_chat_messages_session_id_timestamp", + "unique": false, + "columnNames": [ + "session_id", + "timestamp" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_messages_session_id_timestamp` ON `${TABLE_NAME}` (`session_id`, `timestamp`)" + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7837ef04f86f9b8fca7a52a62b96f610')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt index f6b261ce..98cd564d 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt @@ -28,6 +28,8 @@ data class ChatMessageEntity( val role: String, @ColumnInfo(name = "content") val content: String, + @ColumnInfo(name = "reasoning_text") + val reasoningText: String = "", @ColumnInfo(name = "timestamp") val timestamp: Long, @ColumnInfo(name = "tool_name") diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageMapper.kt b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageMapper.kt index b3876f62..db8f3796 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageMapper.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageMapper.kt @@ -22,6 +22,7 @@ fun ChatMessageEntity.toUiModel(): ChatMessage = else -> MessageRole.ASSISTANT }, content = content, + reasoningText = reasoningText, timestamp = timestamp, isStreaming = isStreaming, toolName = toolName, @@ -40,6 +41,7 @@ fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity = sessionId = sessionId, role = role.name, content = content, + reasoningText = reasoningText, timestamp = timestamp, toolName = toolName, toolStatus = toolStatus?.name, diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt b/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt index 9757049b..fd774413 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt @@ -11,7 +11,7 @@ import java.io.File @Database( entities = [ChatMessageEntity::class], - version = 3, + version = 4, exportSchema = true, ) abstract class HermesDatabase : RoomDatabase() { @@ -45,13 +45,22 @@ abstract class HermesDatabase : RoomDatabase() { } } + val migration3to4 = + object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE `chat_messages` ADD COLUMN `reasoning_text` TEXT NOT NULL DEFAULT ''", + ) + } + } + instance ?: Room .databaseBuilder( context.applicationContext, HermesDatabase::class.java, "hermes_control.db", ).openHelperFactory(factory) - .addMigrations(migration2to3) + .addMigrations(migration2to3, migration3to4) .fallbackToDestructiveMigration(false) .build() .also { instance = it } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt index b3377fb8..72a38fac 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt @@ -62,6 +62,15 @@ object EventParser { WsEvent.ThinkingDelta(token, sessionId) } + "reasoning.delta" -> { + val token = payload?.get("text") as? String ?: "" + WsEvent.ReasoningDelta(token, sessionId) + } + + "reasoning.available" -> { + WsEvent.ReasoningAvailable(sessionId) + } + "message.complete" -> { val text = payload?.get("text") as? String ?: "" WsEvent.MessageComplete(text, sessionId) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt index e41a309f..4d0d234c 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt @@ -30,6 +30,15 @@ sealed class WsEvent { val sessionId: String?, ) : WsEvent() + data class ReasoningDelta( + val token: String, + val sessionId: String?, + ) : WsEvent() + + data class ReasoningAvailable( + val sessionId: String?, + ) : WsEvent() + data class MessageComplete( val text: String, val sessionId: String?, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt index 7a7bbbae..98c3ef5c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt @@ -18,6 +18,7 @@ data class ChatMessage( val id: String = UUID.randomUUID().toString(), val role: MessageRole, val content: String, + val reasoningText: String = "", val timestamp: Long = System.currentTimeMillis(), val isStreaming: Boolean = false, val toolName: String? = null, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index c4f11c0e..2d9e7d9d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement @@ -51,11 +52,14 @@ import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.AttachFile import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Stop +import androidx.compose.material.icons.outlined.Psychology import androidx.compose.material3.AlertDialog import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card @@ -520,7 +524,7 @@ private fun ThinkingIndicator(thinkingText: String) { Card( colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + containerColor = LocalHermesStatusColors.current.infoContainer.copy(alpha = 0.5f), ), shape = RoundedCornerShape(12.dp), ) { @@ -544,7 +548,7 @@ private fun ThinkingIndicator(thinkingText: String) { }, style = MaterialTheme.typography.bodySmall.copy( - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = LocalHermesStatusColors.current.info, ), maxLines = 2, modifier = Modifier.animateContentSize(), @@ -554,6 +558,62 @@ private fun ThinkingIndicator(thinkingText: String) { } } +@Composable +private fun ReasoningIndicator(reasoningText: String) { + var expanded by remember { mutableStateOf(false) } + val statusColors = LocalHermesStatusColors.current + Card( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp) + .clickable( + role = Role.Button, + onClick = { expanded = !expanded }, + ), + colors = + CardDefaults.cardColors( + containerColor = statusColors.infoContainer.copy(alpha = 0.5f), + ), + shape = RoundedCornerShape(12.dp), + ) { + Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Outlined.Psychology, + contentDescription = stringResource(R.string.chat_reasoning), + tint = statusColors.info, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(R.string.chat_reasoning), + style = MaterialTheme.typography.labelMedium, + color = statusColors.info, + ) + Spacer(Modifier.weight(1f)) + Icon( + imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, + contentDescription = + stringResource( + if (expanded) R.string.chat_reasoning_collapse else R.string.chat_reasoning_expand, + ), + tint = statusColors.info, + modifier = Modifier.size(18.dp), + ) + } + AnimatedVisibility(visible = expanded) { + Text( + text = reasoningText, + style = MaterialTheme.typography.bodySmall, + color = statusColors.info, + modifier = Modifier.padding(top = 4.dp), + ) + } + } + } +} + @Composable private fun ChatInputBar( inputText: String, @@ -1649,6 +1709,10 @@ private fun ChatMessageList( val isLastMessage = index == messages.lastIndex val isAssistant = message.role == MessageRole.ASSISTANT + if (isAssistant && message.reasoningText.isNotBlank()) { + ReasoningIndicator(message.reasoningText) + } + if (typingEffectEnabled && isLastMessage && isAssistant && message.isStreaming && lastAnimatedMessageId != message.id ) { @@ -1674,6 +1738,9 @@ private fun ChatMessageList( // Streaming message streamingMessage?.let { streaming -> item(key = "streaming-${streaming.id}") { + if (streaming.reasoningText.isNotBlank()) { + ReasoningIndicator(streaming.reasoningText) + } if (typingEffectEnabled && streaming.isStreaming) { StreamingBubbleWithTypingEffect( streaming = streaming, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatStreamingController.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatStreamingController.kt index 9579eb08..ae39877c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatStreamingController.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatStreamingController.kt @@ -33,6 +33,9 @@ class ChatStreamingController( private val thinkingBuffer = java.lang.StringBuilder() private var lastThinkingFlushMs = 0L + private val reasoningBuffer = java.lang.StringBuilder() + private var lastReasoningFlushMs = 0L + /** * Resets all streaming buffers. Centralizes the clear logic that was * previously scattered across MessageStart, MessageComplete, MessageDone, @@ -41,8 +44,10 @@ class ChatStreamingController( fun resetStreaming() { streamingBuffer.clear() thinkingBuffer.clear() + reasoningBuffer.clear() lastFlushMs = 0L lastThinkingFlushMs = 0L + lastReasoningFlushMs = 0L } /** Resets buffers and starts a fresh streaming message (called on MessageStart). */ @@ -109,4 +114,26 @@ class ChatStreamingController( } } } + + fun handleReasoningDelta(event: WsEvent.ReasoningDelta) { + if (!isCurrentSession(event.sessionId)) return + + reasoningBuffer.append(event.token) + val now = System.currentTimeMillis() + + // Always flush in tests, or if enough time has passed + val shouldFlush = + (now - lastReasoningFlushMs >= 33L) || lastReasoningFlushMs == 0L || isTestEnvironment() + if (shouldFlush) { + val currentContent = reasoningBuffer.toString() + lastReasoningFlushMs = now + streamingState.update { state -> + state.copy( + isReasoning = true, + reasoningText = currentContent, + streamingMessage = state.streamingMessage?.copy(reasoningText = currentContent), + ) + } + } + } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 90c93160..8052c309 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -311,6 +311,10 @@ class ChatViewModel( streamingController.handleThinkingDelta(event) } + is WsEvent.ReasoningDelta -> { + streamingController.handleReasoningDelta(event) + } + is WsEvent.MessageStart -> { streamingController.beginStreamingMessage() } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt index 2fd52bc1..7586f7bd 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt @@ -27,6 +27,7 @@ object ChatWsEventReducer { is WsEvent.MessageStart -> event.sessionId is WsEvent.MessageToken -> event.sessionId is WsEvent.ThinkingDelta -> event.sessionId + is WsEvent.ReasoningDelta -> event.sessionId is WsEvent.MessageComplete -> event.sessionId is WsEvent.MessageDone -> event.sessionId is WsEvent.ToolStart -> event.sessionId @@ -53,6 +54,8 @@ object ChatWsEventReducer { is WsEvent.MessageToken -> onMessageToken(state, streamingState, event) is WsEvent.ThinkingDelta -> onThinkingDelta(state, streamingState, event) + is WsEvent.ReasoningDelta -> onReasoningDelta(state, streamingState, event) + is WsEvent.ReasoningAvailable -> onReasoningAvailable(state, streamingState, event) is WsEvent.MessageComplete -> onMessageComplete(state, streamingState, event) @@ -163,6 +166,34 @@ object ChatWsEventReducer { ) } + // ── ReasoningDelta ──────────────────────────────────────────────── + private fun onReasoningDelta( + state: ChatUiState, + streamingState: StreamingState, + event: WsEvent.ReasoningDelta, + ): ReducerResult { + val currentContent = streamingState.reasoningText + event.token + return ReducerResult( + state = state, + streamingState = + streamingState.copy( + isReasoning = true, + reasoningText = currentContent, + ), + ) + } + + // ── ReasoningAvailable ──────────────────────────────────────────── + private fun onReasoningAvailable( + state: ChatUiState, + streamingState: StreamingState, + event: WsEvent.ReasoningAvailable, + ): ReducerResult = + ReducerResult( + state = state, + streamingState = streamingState.copy(isReasoning = true), + ) + // ── MessageComplete ─────────────────────────────────────────────── private fun onMessageComplete( @@ -175,9 +206,11 @@ object ChatWsEventReducer { streaming?.copy( content = event.text ?: streaming.content, isStreaming = false, + reasoningText = streamingState.reasoningText, ) ?: ChatMessage( role = MessageRole.ASSISTANT, content = event.text ?: "", + reasoningText = streamingState.reasoningText, ) val effects = mutableListOf() val sid = state.currentSessionId @@ -206,7 +239,7 @@ object ChatWsEventReducer { state = state, streamingState = streamingState, ) - val msg = streaming.copy(isStreaming = false) + val msg = streaming.copy(isStreaming = false, reasoningText = streamingState.reasoningText) val effects = mutableListOf() val sid = state.currentSessionId if (sid != null) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/StreamingState.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/StreamingState.kt index 3a06cb3a..dcb0722e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/StreamingState.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/StreamingState.kt @@ -4,4 +4,6 @@ data class StreamingState( val streamingMessage: ChatMessage? = null, val isThinking: Boolean = false, val thinkingText: String = "", + val isReasoning: Boolean = false, + val reasoningText: String = "", ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6cc5e092..c5d0b51e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -104,6 +104,9 @@ Session expired \u2014 please sign in again Thinking\u2026 Thinking: %1$s\u2026 + Reasoning + Expand reasoning + Collapse reasoning Not connected\u2026 Waiting for response\u2026 Type a message\u2026 diff --git a/app/src/test/java/com/m57/hermescontrol/data/local/ChatMessageMapperTest.kt b/app/src/test/java/com/m57/hermescontrol/data/local/ChatMessageMapperTest.kt new file mode 100644 index 00000000..b98924bb --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/local/ChatMessageMapperTest.kt @@ -0,0 +1,59 @@ +package com.m57.hermescontrol.data.local + +import com.m57.hermescontrol.ui.chat.ChatMessage +import com.m57.hermescontrol.ui.chat.MessageRole +import org.junit.Assert.assertEquals +import org.junit.Test + +class ChatMessageMapperTest { + @Test + fun entityToUiModelCarriesReasoningText() { + val entity = + ChatMessageEntity( + id = "msg-1", + sessionId = "session-a", + role = "assistant", + content = "Answer", + reasoningText = "Let me think step by step", + timestamp = 1000L, + ) + + val ui = entity.toUiModel() + + assertEquals("Let me think step by step", ui.reasoningText) + assertEquals("Answer", ui.content) + } + + @Test + fun uiModelToEntityCarriesReasoningText() { + val ui = + ChatMessage( + id = "msg-2", + role = MessageRole.ASSISTANT, + content = "Answer", + reasoningText = "Chain of thought", + timestamp = 2000L, + ) + + val entity = ui.toEntity("session-b") + + assertEquals("Chain of thought", entity.reasoningText) + assertEquals("session-b", entity.sessionId) + } + + @Test + fun roundTripPreservesReasoningText() { + val ui = + ChatMessage( + id = "msg-3", + role = MessageRole.ASSISTANT, + content = "Answer", + reasoningText = "r", + timestamp = 3000L, + ) + + val roundTripped = ui.toEntity("s").toUiModel() + + assertEquals("r", roundTripped.reasoningText) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt index bab85ee6..089ee9d4 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt @@ -166,6 +166,48 @@ class EventParserTest { assertEquals("session-2", deltaEvent.sessionId) } + @Test + fun testParseReasoningDelta_returnsReasoningDeltaEvent() { + val response = + createJsonRpcResponse( + jsonrpc = "2.0", + id = null, + result = null, + error = null, + method = "event", + params = + mapOf( + "type" to "reasoning.delta", + "payload" to mapOf("text" to "reasoning token", "session_id" to "session-r1"), + ), + ) + val event = EventParser.parse(response) + assertTrue(event is WsEvent.ReasoningDelta) + val deltaEvent = event as WsEvent.ReasoningDelta + assertEquals("reasoning token", deltaEvent.token) + assertEquals("session-r1", deltaEvent.sessionId) + } + + @Test + fun testParseReasoningAvailable_returnsReasoningAvailableEvent() { + val response = + createJsonRpcResponse( + jsonrpc = "2.0", + id = null, + result = null, + error = null, + method = "event", + params = + mapOf( + "type" to "reasoning.available", + "payload" to mapOf("session_id" to "session-r1"), + ), + ) + val event = EventParser.parse(response) + assertTrue(event is WsEvent.ReasoningAvailable) + assertEquals("session-r1", (event as WsEvent.ReasoningAvailable).sessionId) + } + @Test fun testParseClarifyRequest_returnsClarifyRequestEvent() { val response = diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index b0a20540..1b5a8627 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -473,6 +473,55 @@ class ChatViewModelTest { ) } + @Test + fun testReasoningStreamingFlow() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + + // 1 — Reasoning available: block becomes visible (no token yet) + mockEventsFlow.emit(WsEvent.ReasoningAvailable(sessionId)) + advanceUntilIdle() + assertTrue(viewModel.streamingState.value.isReasoning) + + // 2 — Reasoning delta + mockEventsFlow.emit(WsEvent.ReasoningDelta("Let me think", sessionId)) + advanceUntilIdle() + assertEquals("Let me think", viewModel.streamingState.value.reasoningText) + + // 3 — Deeper reasoning + mockEventsFlow.emit(WsEvent.ReasoningDelta(" step by step", sessionId)) + advanceUntilIdle() + assertEquals("Let me think step by step", viewModel.streamingState.value.reasoningText) + + // 4 — Thinking still independent + mockEventsFlow.emit(WsEvent.ThinkingDelta("thinking", sessionId)) + advanceUntilIdle() + assertTrue(viewModel.streamingState.value.isThinking) + assertEquals("thinking", viewModel.streamingState.value.thinkingText) + // reasoning untouched + assertEquals("Let me think step by step", viewModel.streamingState.value.reasoningText) + + // 5 — Complete: reducer finalizes message, attaching reasoning + mockEventsFlow.emit(WsEvent.MessageComplete("The answer is 42", sessionId)) + advanceUntilIdle() + + assertNull(viewModel.streamingState.value.streamingMessage) + assertEquals(2, viewModel.uiState.value.messages.size) + assertEquals( + "The answer is 42", + viewModel.uiState.value.messages[1].content, + ) + // reasoning carried onto the finalized UI message + assertEquals( + "Let me think step by step", + viewModel.uiState.value.messages[1].reasoningText, + ) + // reasoning persisted to the entity (survives reload) + val persisted = + fakeRepo.dao.getMessagesForSession(sessionId).first { it.role == "ASSISTANT" } + assertEquals("Let me think step by step", persisted.reasoningText) + } + @Test fun testToolExecution_finalizesPreviousStreamingMessage() = runTest { diff --git a/flake.nix b/flake.nix index b24dfea8..2fbba325 100644 --- a/flake.nix +++ b/flake.nix @@ -22,38 +22,39 @@ }; buildToolsVersion = "36.0.0"; - androidSdk = (pkgs.androidenv.composeAndroidPackages { - # Command-line & platform tools - cmdLineToolsVersion = "11.0"; - platformToolsVersion = "35.0.2"; + androidSdk = + (pkgs.androidenv.composeAndroidPackages { + # Command-line & platform tools + cmdLineToolsVersion = "11.0"; + platformToolsVersion = "36.0.0"; - # Build tools - buildToolsVersions = [buildToolsVersion]; + # Build tools + buildToolsVersions = [buildToolsVersion]; - # Target platforms (36 required by AGP 9.0.1 / compileSdk 36) - platformVersions = ["36"]; + # Target platforms (36 required by AGP 9.0.1 / compileSdk 36) + platformVersions = ["36"]; - # Emulator + system images for local AVD testing - includeEmulator = true; - includeSystemImages = true; - systemImageTypes = ["google_apis"]; - abiVersions = ["x86_64"]; + # Emulator + system images for local AVD testing + includeEmulator = true; + includeSystemImages = true; + systemImageTypes = ["google_apis"]; + abiVersions = ["x86_64"]; - # NDK (not needed for this project, but handy) - includeNDK = false; + # NDK (not needed for this project, but handy) + includeNDK = false; - # Extra packages - extraLicenses = [ - "android-googletv-license" - "android-sdk-arm-dbt-license" - "android-sdk-license" - "android-sdk-preview-license" - "google-gdk-license" - "intel-android-extra-license" - "intel-android-sysimage-license" - "mips-android-sysimage-license" - ]; - }).androidsdk; + # Extra packages + extraLicenses = [ + "android-googletv-license" + "android-sdk-arm-dbt-license" + "android-sdk-license" + "android-sdk-preview-license" + "google-gdk-license" + "intel-android-extra-license" + "intel-android-sysimage-license" + "mips-android-sysimage-license" + ]; + }).androidsdk; in { devShells.default = pkgs.mkShell { buildInputs = with pkgs; [ @@ -82,7 +83,7 @@ GRADLE_USER_HOME = "$PWD/.gradle-home"; shellHook = '' - echo "🤖 HermesControl Android dev shell" + echo " HermesControl Android dev shell" echo " Java: $(java -version 2>&1 | head -1)" echo " Kotlin: $(kotlin -version 2>&1)" echo " Gradle: $(gradle --version 2>&1 | grep '^Gradle' || echo 'available')"