* feat(#525): add ReasoningDelta + ReasoningAvailable WsEvent types * feat(#525): parse reasoning.delta + reasoning.available; route through reducer + StreamingState * feat(#525): add reasoning streaming buffer to ChatStreamingController * feat(#525): dispatch ReasoningDelta/ReasoningAvailable in ChatViewModel * feat(#525): render collapsible ReasoningIndicator in chat list * fix(#525): address review — icons+semantics, LocalHermesStatusColors, drop redundant ReasoningAvailable branch * feat(#525): persist reasoningText on model, entity, mapper + Room migration 3->4 * feat(#525): reducer + streaming carry reasoningText onto message * feat(#525): render persisted reasoning above each assistant bubble, drop global indicator * nix: flake * nix: flake --------- Co-authored-by: M57 <m57@hermes-control.app>
This commit is contained in:
1 parent
d04b74ee0a
commit
53b3966931
17 files changed
+441
-33
No files matched your search
@@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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?,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -311,6 +311,10 @@ class ChatViewModel(
|
||||
streamingController.handleThinkingDelta(event)
|
||||
}
|
||||
|
||||
is WsEvent.ReasoningDelta -> {
|
||||
streamingController.handleReasoningDelta(event)
|
||||
}
|
||||
|
||||
is WsEvent.MessageStart -> {
|
||||
streamingController.beginStreamingMessage()
|
||||
}
|
||||
|
||||
@@ -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<ReducerEffect>()
|
||||
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<ReducerEffect>()
|
||||
val sid = state.currentSessionId
|
||||
if (sid != null) {
|
||||
|
||||
@@ -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 = "",
|
||||
)
|
||||
@@ -104,6 +104,9 @@
|
||||
<string name="chat_status_auth_expired">Session expired \u2014 please sign in again</string>
|
||||
<string name="chat_thinking">Thinking\u2026</string>
|
||||
<string name="chat_thinking_param">Thinking: %1$s\u2026</string>
|
||||
<string name="chat_reasoning">Reasoning</string>
|
||||
<string name="chat_reasoning_expand">Expand reasoning</string>
|
||||
<string name="chat_reasoning_collapse">Collapse reasoning</string>
|
||||
<string name="chat_input_placeholder_not_connected">Not connected\u2026</string>
|
||||
<string name="chat_input_placeholder_waiting">Waiting for response\u2026</string>
|
||||
<string name="chat_input_placeholder_type_message">Type a message\u2026</string>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')"
|
||||
|
||||
Reference in new issue
Block a user