feat(chat): simplify assistant message states (#1131)
This commit is contained in:
15 files changed
+552
-201
No files matched your search
+60
-5
@@ -6,12 +6,14 @@ import androidx.compose.ui.test.assertIsDisplayed
|
|||||||
import androidx.compose.ui.test.junit4.createComposeRule
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
import androidx.compose.ui.test.onAllNodesWithTag
|
import androidx.compose.ui.test.onAllNodesWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.filters.MediumTest
|
import androidx.test.filters.MediumTest
|
||||||
import com.m57.hermescontrol.ui.chat.ChatMessage
|
import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||||
import com.m57.hermescontrol.ui.chat.ChatSearchState
|
import com.m57.hermescontrol.ui.chat.ChatSearchState
|
||||||
import com.m57.hermescontrol.ui.chat.ChatViewModel
|
import com.m57.hermescontrol.ui.chat.ChatViewModel
|
||||||
import com.m57.hermescontrol.ui.chat.MessageRole
|
import com.m57.hermescontrol.ui.chat.MessageRole
|
||||||
|
import com.m57.hermescontrol.ui.chat.StreamingState
|
||||||
import com.m57.hermescontrol.ui.chat.ToolStatus
|
import com.m57.hermescontrol.ui.chat.ToolStatus
|
||||||
import com.m57.hermescontrol.ui.chat.components.ChatScrollController
|
import com.m57.hermescontrol.ui.chat.components.ChatScrollController
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
@@ -47,19 +49,21 @@ class FullBleedChatListTest {
|
|||||||
private fun render(
|
private fun render(
|
||||||
messages: List<ChatMessage>,
|
messages: List<ChatMessage>,
|
||||||
streamingMessage: ChatMessage? = null,
|
streamingMessage: ChatMessage? = null,
|
||||||
|
streamingState: StreamingState = StreamingState(streamingMessage = streamingMessage),
|
||||||
|
isAgentTyping: Boolean = streamingMessage?.isStreaming == true,
|
||||||
clarify: Boolean = false,
|
clarify: Boolean = false,
|
||||||
) {
|
) {
|
||||||
composeTestRule.setContent {
|
composeTestRule.setContent {
|
||||||
val listState = LazyListState()
|
val listState = LazyListState()
|
||||||
FullBleedChatList(
|
FullBleedChatList(
|
||||||
messages = messages,
|
messages = messages,
|
||||||
streamingMessage = streamingMessage,
|
streamingState = streamingState,
|
||||||
|
isAgentTyping = isAgentTyping,
|
||||||
searchState = ChatSearchState(),
|
searchState = ChatSearchState(),
|
||||||
typingEffectEnabled = false,
|
typingEffectEnabled = false,
|
||||||
typingEffectDelayMs = 30,
|
typingEffectDelayMs = 30,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
isLoadingOlder = false,
|
isLoadingOlder = false,
|
||||||
isDark = false,
|
|
||||||
listState = listState,
|
listState = listState,
|
||||||
scrollController =
|
scrollController =
|
||||||
ChatScrollController(
|
ChatScrollController(
|
||||||
@@ -115,7 +119,7 @@ class FullBleedChatListTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun turnHeader_renderedOncePerAgentTurn() {
|
fun assistantHeader_isRemovedFromEveryAgentTurn() {
|
||||||
render(
|
render(
|
||||||
listOf(
|
listOf(
|
||||||
msg("a1", MessageRole.ASSISTANT),
|
msg("a1", MessageRole.ASSISTANT),
|
||||||
@@ -125,8 +129,8 @@ class FullBleedChatListTest {
|
|||||||
msg("a3", MessageRole.ASSISTANT),
|
msg("a3", MessageRole.ASSISTANT),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
// Two agent turns (a1..a2, a3) -> two turn headers (one per turn).
|
composeTestRule.onAllNodesWithTag("fullbleed_agent_header").assertCountEquals(0)
|
||||||
composeTestRule.onAllNodesWithTag("fullbleed_agent_header").assertCountEquals(2)
|
composeTestRule.onNodeWithTag("fullbleed_finish_time").assertDoesNotExist()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -139,4 +143,55 @@ class FullBleedChatListTest {
|
|||||||
composeTestRule.onNodeWithTag("fullbleed_agent_message").assertIsDisplayed()
|
composeTestRule.onNodeWithTag("fullbleed_agent_message").assertIsDisplayed()
|
||||||
composeTestRule.onNodeWithTag("clarify_bubble").assertIsDisplayed()
|
composeTestRule.onNodeWithTag("clarify_bubble").assertIsDisplayed()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun emptyStreamingMessage_showsTypingStatus_withoutEmptyAssistantProse() {
|
||||||
|
render(
|
||||||
|
messages = listOf(msg("u1", MessageRole.USER)),
|
||||||
|
streamingMessage = msg("s1", MessageRole.ASSISTANT, content = "", isStreaming = true),
|
||||||
|
isAgentTyping = true,
|
||||||
|
)
|
||||||
|
composeTestRule.onNodeWithTag("typing_indicator").assertIsDisplayed()
|
||||||
|
composeTestRule.onNodeWithTag("fullbleed_agent_message").assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reasoningStreaming_showsThinkingStatus_andKeepsReasoningCard() {
|
||||||
|
val streaming =
|
||||||
|
msg(
|
||||||
|
"s1",
|
||||||
|
MessageRole.ASSISTANT,
|
||||||
|
content = "",
|
||||||
|
isStreaming = true,
|
||||||
|
).copy(reasoningText = "step")
|
||||||
|
render(
|
||||||
|
messages = listOf(msg("u1", MessageRole.USER)),
|
||||||
|
streamingMessage = streaming,
|
||||||
|
streamingState =
|
||||||
|
StreamingState(
|
||||||
|
streamingMessage = streaming,
|
||||||
|
isReasoning = true,
|
||||||
|
reasoningText = "step",
|
||||||
|
),
|
||||||
|
isAgentTyping = true,
|
||||||
|
)
|
||||||
|
composeTestRule.onNodeWithTag("agent_status_thinking").assertIsDisplayed()
|
||||||
|
composeTestRule.onNodeWithTag("reasoning_card").assertIsDisplayed()
|
||||||
|
composeTestRule.onNodeWithTag("typing_indicator").assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun runningTool_showsToolStatus_insteadOfTypingDots() {
|
||||||
|
render(
|
||||||
|
messages =
|
||||||
|
listOf(
|
||||||
|
msg("u1", MessageRole.USER),
|
||||||
|
msg("t1", MessageRole.TOOL, toolStatus = ToolStatus.RUNNING).copy(toolName = "web_search"),
|
||||||
|
),
|
||||||
|
isAgentTyping = true,
|
||||||
|
)
|
||||||
|
composeTestRule.onNodeWithTag("agent_status_tool").assertIsDisplayed()
|
||||||
|
composeTestRule.onNodeWithText("Searching…").assertIsDisplayed()
|
||||||
|
composeTestRule.onNodeWithTag("typing_indicator").assertDoesNotExist()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,6 @@ import androidx.compose.animation.shrinkVertically
|
|||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -359,7 +358,6 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
var viewingImage by rememberSaveable { mutableStateOf<ImageViewerModel?>(null) }
|
var viewingImage by rememberSaveable { mutableStateOf<ImageViewerModel?>(null) }
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val isDark = isSystemInDarkTheme()
|
|
||||||
val launchExternalActivity: (() -> Unit) -> Unit = { launch ->
|
val launchExternalActivity: (() -> Unit) -> Unit = { launch ->
|
||||||
ExternalActivityLifecycleGuard.launchExternalActivity(
|
ExternalActivityLifecycleGuard.launchExternalActivity(
|
||||||
acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease,
|
acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease,
|
||||||
@@ -668,14 +666,14 @@ fun ChatScreen(
|
|||||||
// surface since the bubble renderer was removed.
|
// surface since the bubble renderer was removed.
|
||||||
FullBleedChatList(
|
FullBleedChatList(
|
||||||
messages = state.messages,
|
messages = state.messages,
|
||||||
streamingMessage = streamingState.streamingMessage,
|
streamingState = streamingState,
|
||||||
|
isAgentTyping = state.isAgentTyping,
|
||||||
searchState = searchState,
|
searchState = searchState,
|
||||||
typingEffectEnabled = state.typingEffectEnabled,
|
typingEffectEnabled = state.typingEffectEnabled,
|
||||||
typingEffectDelayMs = state.typingEffectDelayMs,
|
typingEffectDelayMs = state.typingEffectDelayMs,
|
||||||
maxToolCallsPerTurn = state.maxToolCallsPerTurn,
|
maxToolCallsPerTurn = state.maxToolCallsPerTurn,
|
||||||
isLoading = state.isLoading,
|
isLoading = state.isLoading,
|
||||||
isLoadingOlder = state.isLoadingOlder,
|
isLoadingOlder = state.isLoadingOlder,
|
||||||
isDark = isDark,
|
|
||||||
listState = listState,
|
listState = listState,
|
||||||
scrollController = scrollController,
|
scrollController = scrollController,
|
||||||
lastAnimatedMessageId = lastAnimatedMessageId,
|
lastAnimatedMessageId = lastAnimatedMessageId,
|
||||||
|
|||||||
@@ -1518,6 +1518,16 @@ class ChatViewModel(
|
|||||||
else -> error.toString()
|
else -> error.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (method == WsMethods.PROMPT_SUBMIT || method == WsMethods.SESSION_REDIRECT) {
|
||||||
|
// A prompt rejection can arrive before message.start, leaving the
|
||||||
|
// UI with only the optimistic typing state. Clear the live tail so
|
||||||
|
// a failed generation cannot leave stale dots or reasoning behind.
|
||||||
|
sealStreamingMessageIfAny()
|
||||||
|
_uiState.update { it.copy(isAgentTyping = false) }
|
||||||
|
_streamingState.update { StreamingState() }
|
||||||
|
streamingController.resetStreaming()
|
||||||
|
}
|
||||||
|
|
||||||
// Session resume failures go through the bounded retry (desktop
|
// Session resume failures go through the bounded retry (desktop
|
||||||
// parity) instead of a one-shot snackbar — the session may be
|
// parity) instead of a one-shot snackbar — the session may be
|
||||||
// mid-flush on the gateway or the WS may have just rebound, and a
|
// mid-flush on the gateway or the WS may have just rebound, and a
|
||||||
|
|||||||
@@ -456,10 +456,11 @@ object ChatWsEventReducer {
|
|||||||
streamingState: StreamingState,
|
streamingState: StreamingState,
|
||||||
): ReducerResult {
|
): ReducerResult {
|
||||||
val streaming =
|
val streaming =
|
||||||
streamingState.streamingMessage ?: return ReducerResult(
|
streamingState.streamingMessage
|
||||||
state = state,
|
?: return ReducerResult(
|
||||||
streamingState = streamingState,
|
state = state.copy(isAgentTyping = false),
|
||||||
)
|
streamingState = streamingState,
|
||||||
|
)
|
||||||
val reasoning =
|
val reasoning =
|
||||||
if (streamingState.reasoningText.isNotBlank()) {
|
if (streamingState.reasoningText.isNotBlank()) {
|
||||||
streamingState.reasoningText
|
streamingState.reasoningText
|
||||||
@@ -578,7 +579,12 @@ object ChatWsEventReducer {
|
|||||||
val nextTodos = parsedTodos ?: newState.todos
|
val nextTodos = parsedTodos ?: newState.todos
|
||||||
|
|
||||||
return ReducerResult(
|
return ReducerResult(
|
||||||
state = newState.copy(messages = newState.messages + toolMessage, todos = nextTodos),
|
state =
|
||||||
|
newState.copy(
|
||||||
|
messages = newState.messages + toolMessage,
|
||||||
|
isAgentTyping = true,
|
||||||
|
todos = nextTodos,
|
||||||
|
),
|
||||||
streamingState = finalStreamingState,
|
streamingState = finalStreamingState,
|
||||||
effects = effects,
|
effects = effects,
|
||||||
)
|
)
|
||||||
@@ -810,6 +816,7 @@ object ChatWsEventReducer {
|
|||||||
ReducerResult(
|
ReducerResult(
|
||||||
state =
|
state =
|
||||||
state.copy(
|
state.copy(
|
||||||
|
isAgentTyping = false,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
errorMessage = "Backend error: ${event.message ?: "Unknown gateway error"}",
|
errorMessage = "Backend error: ${event.message ?: "Unknown gateway error"}",
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ import androidx.compose.ui.platform.LocalConfiguration
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
@@ -746,13 +748,19 @@ fun SubagentCard(
|
|||||||
// ── TypingIndicator ───────────────────────────────────────────────────────
|
// ── TypingIndicator ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Three bouncing dots shown while the assistant is typing / thinking.
|
* Three subtle dots shown while the assistant is waiting to produce visible
|
||||||
* Staggered animation: 0ms, 150ms, 300ms delay per dot.
|
* content. Staggered opacity/scale animation keeps the indicator lightweight
|
||||||
|
* without the distracting vertical bounce used by the old chat renderer.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun TypingIndicator(modifier: Modifier = Modifier) {
|
fun TypingIndicator(modifier: Modifier = Modifier) {
|
||||||
|
val description = stringResource(R.string.chat_agent_status_typing)
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp).testTag("typing_indicator"),
|
modifier =
|
||||||
|
modifier
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
.semantics { contentDescription = description }
|
||||||
|
.testTag("typing_indicator"),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
@@ -768,18 +776,18 @@ private fun TypingDot(delayMs: Int) {
|
|||||||
val typingSpec: InfiniteRepeatableSpec<Float> =
|
val typingSpec: InfiniteRepeatableSpec<Float> =
|
||||||
remember(delayMs) {
|
remember(delayMs) {
|
||||||
infiniteRepeatable(
|
infiniteRepeatable(
|
||||||
animation = tween(400, delayMillis = delayMs, easing = LinearEasing),
|
animation = tween(700, delayMillis = delayMs, easing = LinearEasing),
|
||||||
repeatMode = RepeatMode.Reverse,
|
repeatMode = RepeatMode.Reverse,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val offset by infiniteTransition.animateFloat(
|
val offset by infiniteTransition.animateFloat(
|
||||||
initialValue = 0f,
|
initialValue = 0.85f,
|
||||||
targetValue = -6f,
|
targetValue = 1f,
|
||||||
animationSpec = typingSpec,
|
animationSpec = typingSpec,
|
||||||
label = "typing_dot_offset_$delayMs",
|
label = "typing_dot_scale_$delayMs",
|
||||||
)
|
)
|
||||||
val alpha by infiniteTransition.animateFloat(
|
val alpha by infiniteTransition.animateFloat(
|
||||||
initialValue = 0.4f,
|
initialValue = 0.45f,
|
||||||
targetValue = 1f,
|
targetValue = 1f,
|
||||||
animationSpec = typingSpec,
|
animationSpec = typingSpec,
|
||||||
label = "typing_dot_alpha_$delayMs",
|
label = "typing_dot_alpha_$delayMs",
|
||||||
@@ -790,7 +798,8 @@ private fun TypingDot(delayMs: Int) {
|
|||||||
.size(8.dp)
|
.size(8.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
this.translationY = offset
|
this.scaleX = offset
|
||||||
|
this.scaleY = offset
|
||||||
this.alpha = alpha
|
this.alpha = alpha
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.fullbleed
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||||
|
import com.m57.hermescontrol.ui.chat.MessageRole
|
||||||
|
import com.m57.hermescontrol.ui.chat.StreamingState
|
||||||
|
import com.m57.hermescontrol.ui.chat.ToolSchemaRegistry
|
||||||
|
import com.m57.hermescontrol.ui.chat.ToolStatus
|
||||||
|
import com.m57.hermescontrol.ui.chat.components.TypingIndicator
|
||||||
|
import com.m57.hermescontrol.ui.chat.isUserTurnBoundary
|
||||||
|
|
||||||
|
/** Live status for an agent turn before visible assistant prose is available. */
|
||||||
|
internal sealed interface AgentStatus {
|
||||||
|
data object Typing : AgentStatus
|
||||||
|
|
||||||
|
data object Thinking : AgentStatus
|
||||||
|
|
||||||
|
data class Tool(
|
||||||
|
val name: String?,
|
||||||
|
) : AgentStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives the one status that should occupy the agent turn's live tail.
|
||||||
|
*
|
||||||
|
* Tool execution wins over generic states, and visible streaming prose wins
|
||||||
|
* over waiting/reasoning states. This keeps one indicator on screen at a time
|
||||||
|
* without introducing a second generation state store.
|
||||||
|
*/
|
||||||
|
internal fun deriveAgentStatus(
|
||||||
|
isAgentTyping: Boolean,
|
||||||
|
streamingState: StreamingState,
|
||||||
|
messages: List<ChatMessage>,
|
||||||
|
): AgentStatus? {
|
||||||
|
if (!isAgentTyping) return null
|
||||||
|
|
||||||
|
val currentTurnStart = messages.indexOfLast { it.isUserTurnBoundary() }
|
||||||
|
val currentTurnMessages =
|
||||||
|
if (currentTurnStart >= 0) {
|
||||||
|
messages.drop(currentTurnStart + 1)
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
val activeTool =
|
||||||
|
currentTurnMessages
|
||||||
|
.asReversed()
|
||||||
|
.firstOrNull {
|
||||||
|
it.role == MessageRole.TOOL &&
|
||||||
|
it.toolStatus == ToolStatus.RUNNING
|
||||||
|
}
|
||||||
|
val hasVisibleStreamingContent = streamingState.streamingMessage?.content?.isNotBlank() == true
|
||||||
|
|
||||||
|
return when {
|
||||||
|
activeTool != null -> AgentStatus.Tool(activeTool.toolName)
|
||||||
|
hasVisibleStreamingContent -> null
|
||||||
|
streamingState.isThinking || streamingState.isReasoning -> AgentStatus.Thinking
|
||||||
|
isAgentTyping -> AgentStatus.Typing
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps existing canonical tool names to compact, user-facing activity text. */
|
||||||
|
internal fun toolStatusLabelRes(toolName: String?): Int =
|
||||||
|
when (toolName?.lowercase()) {
|
||||||
|
"web_search",
|
||||||
|
"search_files",
|
||||||
|
"session_search",
|
||||||
|
"tool_search",
|
||||||
|
"x_search",
|
||||||
|
-> R.string.chat_agent_status_searching
|
||||||
|
|
||||||
|
"read_file",
|
||||||
|
-> R.string.chat_agent_status_reading
|
||||||
|
|
||||||
|
"web_extract",
|
||||||
|
"browser_navigate",
|
||||||
|
"browser_click",
|
||||||
|
"browser_type",
|
||||||
|
"browser_scroll",
|
||||||
|
"browser_back",
|
||||||
|
"browser_press",
|
||||||
|
"browser_snapshot",
|
||||||
|
"browser_get_images",
|
||||||
|
"browser_console",
|
||||||
|
"browser_cdp",
|
||||||
|
"browser_dialog",
|
||||||
|
"browser_vision",
|
||||||
|
-> R.string.chat_agent_status_browsing
|
||||||
|
|
||||||
|
"terminal",
|
||||||
|
"execute_code",
|
||||||
|
"process",
|
||||||
|
"process_manage",
|
||||||
|
-> R.string.chat_agent_status_running_command
|
||||||
|
|
||||||
|
else -> R.string.chat_agent_status_using_tool
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun AgentStatusIndicator(
|
||||||
|
status: AgentStatus,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
when (status) {
|
||||||
|
AgentStatus.Typing -> {
|
||||||
|
TypingIndicator(modifier = modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
AgentStatus.Thinking -> {
|
||||||
|
StatusText(
|
||||||
|
text = stringResource(R.string.chat_agent_status_thinking),
|
||||||
|
icon = null,
|
||||||
|
modifier = modifier.testTag("agent_status_thinking"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is AgentStatus.Tool -> {
|
||||||
|
val config = ToolSchemaRegistry.getDisplayConfig(status.name)
|
||||||
|
val labelRes = toolStatusLabelRes(status.name)
|
||||||
|
StatusText(
|
||||||
|
text = stringResource(labelRes),
|
||||||
|
icon = config.icon,
|
||||||
|
modifier = modifier.testTag("agent_status_tool"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StatusText(
|
||||||
|
text: String,
|
||||||
|
icon: ImageVector?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
if (icon != null) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-111
@@ -1,7 +1,6 @@
|
|||||||
package com.m57.hermescontrol.ui.chat.fullbleed
|
package com.m57.hermescontrol.ui.chat.fullbleed
|
||||||
|
|
||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
import android.text.format.DateFormat
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -9,7 +8,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
@@ -29,10 +27,8 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.ClipEntry
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
import androidx.compose.ui.platform.LocalClipboard
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.data.model.Attachment
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
@@ -40,7 +36,6 @@ import com.m57.hermescontrol.ui.chat.ChatMessage
|
|||||||
import com.m57.hermescontrol.ui.chat.ImageViewerModel
|
import com.m57.hermescontrol.ui.chat.ImageViewerModel
|
||||||
import com.m57.hermescontrol.ui.chat.InlineAttachment
|
import com.m57.hermescontrol.ui.chat.InlineAttachment
|
||||||
import com.m57.hermescontrol.ui.chat.MarkdownText
|
import com.m57.hermescontrol.ui.chat.MarkdownText
|
||||||
import com.m57.hermescontrol.ui.chat.TokenEstimator
|
|
||||||
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
|
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -50,17 +45,12 @@ import kotlinx.coroutines.launch
|
|||||||
*
|
*
|
||||||
* Unlike [com.m57.hermescontrol.ui.chat.ChatBubble], agent prose renders
|
* Unlike [com.m57.hermescontrol.ui.chat.ChatBubble], agent prose renders
|
||||||
* directly on the background — no bubble container, no width cap — with a
|
* directly on the background — no bubble container, no width cap — with a
|
||||||
* turn header (role + time) and a trailing copy affordance. User messages
|
* trailing copy affordance. User messages keep their bubbles; this composable
|
||||||
* keep their bubbles; this composable is only used for ASSISTANT messages.
|
* is only used for ASSISTANT messages.
|
||||||
*
|
|
||||||
* [showTurnHeader] renders the role/time header — true only for the first
|
|
||||||
* prose entry of an agent turn so multi-message turns don't repeat it.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun FullBleedAgentMessage(
|
internal fun FullBleedAgentMessage(
|
||||||
message: ChatMessage,
|
message: ChatMessage,
|
||||||
showTurnHeader: Boolean,
|
|
||||||
isDarkTheme: Boolean,
|
|
||||||
searchQuery: String = "",
|
searchQuery: String = "",
|
||||||
isCurrentMatch: Boolean = false,
|
isCurrentMatch: Boolean = false,
|
||||||
showReasoning: Boolean = true,
|
showReasoning: Boolean = true,
|
||||||
@@ -92,10 +82,6 @@ internal fun FullBleedAgentMessage(
|
|||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
.testTag("fullbleed_agent_message"),
|
.testTag("fullbleed_agent_message"),
|
||||||
) {
|
) {
|
||||||
if (showTurnHeader) {
|
|
||||||
AssistantTurnHeader(message.timestamp)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showReasoning && message.reasoningText.isNotBlank()) {
|
if (showReasoning && message.reasoningText.isNotBlank()) {
|
||||||
ReasoningCard(
|
ReasoningCard(
|
||||||
reasoningText = message.reasoningText,
|
reasoningText = message.reasoningText,
|
||||||
@@ -105,10 +91,10 @@ internal fun FullBleedAgentMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Defense-in-depth: never render an empty prose block (blank bubble +
|
// Defense-in-depth: never render an empty prose block (blank bubble +
|
||||||
// lone Copy button). Blank settled rows are tool-call placeholders that
|
// lone Copy button). Blank rows are tool-call placeholders that slipped
|
||||||
// slipped through upstream mapping; streaming keeps rendering so the
|
// through upstream mapping; the parent list renders the live status
|
||||||
// live cursor survives until the first delta lands.
|
// indicator until the first visible delta lands.
|
||||||
if (message.content.isNotBlank() || message.isStreaming) {
|
if (message.content.isNotBlank()) {
|
||||||
SelectionContainer {
|
SelectionContainer {
|
||||||
MarkdownText(
|
MarkdownText(
|
||||||
text = message.content,
|
text = message.content,
|
||||||
@@ -140,7 +126,7 @@ internal fun FullBleedAgentMessage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!message.isStreaming) {
|
if (!message.isStreaming && message.content.isNotBlank()) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
@@ -160,97 +146,7 @@ internal fun FullBleedAgentMessage(
|
|||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
val finishTime = message.finishTimestamp ?: message.timestamp
|
|
||||||
Text(
|
|
||||||
text =
|
|
||||||
com.m57.hermescontrol.ui.chat.formatTimestamp(
|
|
||||||
finishTime,
|
|
||||||
DateFormat.is24HourFormat(LocalContext.current),
|
|
||||||
),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
|
||||||
modifier = Modifier.testTag("fullbleed_finish_time"),
|
|
||||||
)
|
|
||||||
if (message.tokenCount != null && message.tokenCount > 0) {
|
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
|
||||||
Text(
|
|
||||||
text = "•",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
|
||||||
Text(
|
|
||||||
text =
|
|
||||||
stringResource(
|
|
||||||
R.string.chat_msg_tokens,
|
|
||||||
TokenEstimator.formatTokenCount(message.tokenCount),
|
|
||||||
),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
|
||||||
modifier = Modifier.testTag("fullbleed_token_count"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (message.tps != null && message.tps > 0.0) {
|
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
|
||||||
Text(
|
|
||||||
text = "•",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
|
||||||
Text(
|
|
||||||
text =
|
|
||||||
stringResource(
|
|
||||||
R.string.chat_msg_tps,
|
|
||||||
TokenEstimator.formatTps(message.tps),
|
|
||||||
),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
|
||||||
modifier = Modifier.testTag("fullbleed_tps"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Agent turn header — "Agent · <time>". Shared by the prose message and the
|
|
||||||
* hoisted reasoning block so both can lead with the assistant identity and
|
|
||||||
* timestamp.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun AssistantTurnHeader(
|
|
||||||
timestamp: Long,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = modifier.padding(bottom = 4.dp).testTag("fullbleed_agent_header"),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.fullbleed_role_agent),
|
|
||||||
style =
|
|
||||||
MaterialTheme.typography.labelMedium.copy(
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
),
|
|
||||||
// Meta color (not primary): Nord's light mode reuses its
|
|
||||||
// pastel Frost accent as primary, which fails >= 3:1 on a
|
|
||||||
// light background. The header is metadata, so the dimmed
|
|
||||||
// meta token is both more correct and gate-compliant in
|
|
||||||
// every preset.
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
text =
|
|
||||||
com.m57.hermescontrol.ui.chat.formatTimestamp(
|
|
||||||
timestamp,
|
|
||||||
DateFormat.is24HourFormat(LocalContext.current),
|
|
||||||
),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -27,6 +27,7 @@ import com.m57.hermescontrol.ui.chat.ChatSearchState
|
|||||||
import com.m57.hermescontrol.ui.chat.ChatViewModel
|
import com.m57.hermescontrol.ui.chat.ChatViewModel
|
||||||
import com.m57.hermescontrol.ui.chat.ClarifyUi
|
import com.m57.hermescontrol.ui.chat.ClarifyUi
|
||||||
import com.m57.hermescontrol.ui.chat.ImageViewerModel
|
import com.m57.hermescontrol.ui.chat.ImageViewerModel
|
||||||
|
import com.m57.hermescontrol.ui.chat.StreamingState
|
||||||
import com.m57.hermescontrol.ui.chat.ToolCallDivider
|
import com.m57.hermescontrol.ui.chat.ToolCallDivider
|
||||||
import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
|
import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
|
||||||
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
|
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
|
||||||
@@ -43,8 +44,8 @@ import com.m57.hermescontrol.ui.common.EmptyState
|
|||||||
/**
|
/**
|
||||||
* The chat message list for FULL-BLEED style (issue #866) — the single chat
|
* The chat message list for FULL-BLEED style (issue #866) — the single chat
|
||||||
* surface since the bubble renderer was removed. User messages keep their
|
* surface since the bubble renderer was removed. User messages keep their
|
||||||
* bubble (the universal anchor), agent turns render full-bleed with a turn
|
* bubble (the universal anchor), agent turns render full-bleed, and tool rows
|
||||||
* header, and tool rows / system events render as distinct compact cards.
|
* / system events render as distinct compact cards.
|
||||||
* Spacing contract:
|
* Spacing contract:
|
||||||
* - intra-turn: entries separated by 6.dp (Column padding on agent turn items)
|
* - intra-turn: entries separated by 6.dp (Column padding on agent turn items)
|
||||||
* - inter-turn: 12.dp bottom padding after each turn's last item
|
* - inter-turn: 12.dp bottom padding after each turn's last item
|
||||||
@@ -52,20 +53,20 @@ import com.m57.hermescontrol.ui.common.EmptyState
|
|||||||
* COMPOSE GOTCHA (verified): LazyColumn `item {}` content lambdas execute
|
* COMPOSE GOTCHA (verified): LazyColumn `item {}` content lambdas execute
|
||||||
* LAZILY at item-composition time, not during this DSL-building loop. Loop
|
* LAZILY at item-composition time, not during this DSL-building loop. Loop
|
||||||
* locals that are read inside item lambdas must be captured as immutable
|
* locals that are read inside item lambdas must be captured as immutable
|
||||||
* vals FIRST (eagerly), or every item sees the loop's final value — the
|
* vals FIRST (eagerly), or every item sees the loop's final value and
|
||||||
* turn header would never render and milestones would be misindexed.
|
* per-entry state can be rendered against the wrong message.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun FullBleedChatList(
|
fun FullBleedChatList(
|
||||||
messages: List<ChatMessage>,
|
messages: List<ChatMessage>,
|
||||||
streamingMessage: ChatMessage?,
|
streamingState: StreamingState,
|
||||||
|
isAgentTyping: Boolean,
|
||||||
searchState: ChatSearchState,
|
searchState: ChatSearchState,
|
||||||
typingEffectEnabled: Boolean,
|
typingEffectEnabled: Boolean,
|
||||||
typingEffectDelayMs: Int,
|
typingEffectDelayMs: Int,
|
||||||
maxToolCallsPerTurn: Int? = null,
|
maxToolCallsPerTurn: Int? = null,
|
||||||
isLoading: Boolean,
|
isLoading: Boolean,
|
||||||
isLoadingOlder: Boolean,
|
isLoadingOlder: Boolean,
|
||||||
isDark: Boolean,
|
|
||||||
listState: androidx.compose.foundation.lazy.LazyListState,
|
listState: androidx.compose.foundation.lazy.LazyListState,
|
||||||
scrollController: ChatScrollController,
|
scrollController: ChatScrollController,
|
||||||
lastAnimatedMessageId: String?,
|
lastAnimatedMessageId: String?,
|
||||||
@@ -89,7 +90,7 @@ fun FullBleedChatList(
|
|||||||
openingAttachmentPath: String? = null,
|
openingAttachmentPath: String? = null,
|
||||||
onImageClick: (ImageViewerModel) -> Unit = {},
|
onImageClick: (ImageViewerModel) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
if (messages.isEmpty() && !isLoading) {
|
if (messages.isEmpty() && !isLoading && !isAgentTyping) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
@@ -102,9 +103,10 @@ fun FullBleedChatList(
|
|||||||
} else {
|
} else {
|
||||||
val toolMilestones = remember(messages) { toolCallMilestones(messages) }
|
val toolMilestones = remember(messages) { toolCallMilestones(messages) }
|
||||||
val turns =
|
val turns =
|
||||||
remember(messages, streamingMessage) {
|
remember(messages, streamingState.streamingMessage) {
|
||||||
groupIntoTurnsWithStreaming(messages, streamingMessage)
|
groupIntoTurnsWithStreaming(messages, streamingState.streamingMessage)
|
||||||
}
|
}
|
||||||
|
val agentStatus = deriveAgentStatus(isAgentTyping, streamingState, messages)
|
||||||
// messageId → LazyColumn item index. The lazy list has EXTRA items
|
// messageId → LazyColumn item index. The lazy list has EXTRA items
|
||||||
// vs the message list (reasoning hoists, tool rows), so search-match
|
// vs the message list (reasoning hoists, tool rows), so search-match
|
||||||
// scrolling must resolve the lazy index, not use the message index.
|
// scrolling must resolve the lazy index, not use the message index.
|
||||||
@@ -188,7 +190,6 @@ fun FullBleedChatList(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is ChatTurn.Agent -> {
|
is ChatTurn.Agent -> {
|
||||||
var firstProseSeen = false
|
|
||||||
// Reasoning hoist: the turn's reasoning renders at the
|
// Reasoning hoist: the turn's reasoning renders at the
|
||||||
// TOP of the turn — above tool rows — so thinking
|
// TOP of the turn — above tool rows — so thinking
|
||||||
// leads, then the tool work, then the answer. The
|
// leads, then the tool work, then the answer. The
|
||||||
@@ -201,9 +202,6 @@ fun FullBleedChatList(
|
|||||||
val reasoning = turnReasoning.message
|
val reasoning = turnReasoning.message
|
||||||
item(key = "reasoning-${reasoning.id}") {
|
item(key = "reasoning-${reasoning.id}") {
|
||||||
Column(modifier = Modifier.padding(bottom = 6.dp)) {
|
Column(modifier = Modifier.padding(bottom = 6.dp)) {
|
||||||
// Lead the turn with the assistant identity
|
|
||||||
// and timestamp, then the thinking block.
|
|
||||||
AssistantTurnHeader(timestamp = reasoning.timestamp)
|
|
||||||
ReasoningCard(
|
ReasoningCard(
|
||||||
reasoningText = reasoning.reasoningText,
|
reasoningText = reasoning.reasoningText,
|
||||||
isStreaming = reasoning.isStreaming,
|
isStreaming = reasoning.isStreaming,
|
||||||
@@ -215,50 +213,46 @@ fun FullBleedChatList(
|
|||||||
when (entry) {
|
when (entry) {
|
||||||
is AgentEntry.Prose -> {
|
is AgentEntry.Prose -> {
|
||||||
val proseMessage = entry.message
|
val proseMessage = entry.message
|
||||||
val showTurnHeader = !firstProseSeen && turnReasoning == null
|
|
||||||
val hoistedReasoning =
|
val hoistedReasoning =
|
||||||
turnReasoning != null &&
|
turnReasoning != null &&
|
||||||
proseMessage.id == turnReasoning.message.id
|
proseMessage.id == turnReasoning.message.id
|
||||||
item(key = "prose-${proseMessage.id}") {
|
if (proseMessage.hasVisibleAgentContent()) {
|
||||||
Column(modifier = Modifier.padding(bottom = 12.dp)) {
|
item(key = "prose-${proseMessage.id}") {
|
||||||
if (proseMessage.isStreaming && typingEffectEnabled) {
|
Column(modifier = Modifier.padding(bottom = 12.dp)) {
|
||||||
StreamingFullBleedWithTypingEffect(
|
if (proseMessage.isStreaming && typingEffectEnabled) {
|
||||||
streaming = proseMessage,
|
StreamingFullBleedWithTypingEffect(
|
||||||
typingDelayMs = typingEffectDelayMs,
|
streaming = proseMessage,
|
||||||
isDark = isDark,
|
typingDelayMs = typingEffectDelayMs,
|
||||||
showTurnHeader = showTurnHeader,
|
showReasoning = !hoistedReasoning,
|
||||||
showReasoning = !hoistedReasoning,
|
)
|
||||||
)
|
} else {
|
||||||
} else {
|
FullBleedAgentMessage(
|
||||||
FullBleedAgentMessage(
|
message = proseMessage,
|
||||||
message = proseMessage,
|
// Highlight only bubbles that actually contain a match —
|
||||||
showTurnHeader = showTurnHeader,
|
// the rest skip the highlight scan entirely.
|
||||||
isDarkTheme = isDark,
|
searchQuery =
|
||||||
// Highlight only bubbles that actually contain a match —
|
if (searchState.isActive &&
|
||||||
// the rest skip the highlight scan entirely.
|
proseMessage.id in searchState.matchedIds
|
||||||
searchQuery =
|
) {
|
||||||
if (searchState.isActive &&
|
searchState.query
|
||||||
proseMessage.id in searchState.matchedIds
|
} else {
|
||||||
) {
|
""
|
||||||
searchState.query
|
},
|
||||||
} else {
|
isCurrentMatch =
|
||||||
""
|
searchState.currentMatchId != null &&
|
||||||
},
|
searchState.currentMatchId == proseMessage.id,
|
||||||
isCurrentMatch =
|
showReasoning = !hoistedReasoning,
|
||||||
searchState.currentMatchId != null &&
|
onOpenAttachment = viewModel::openAttachment,
|
||||||
searchState.currentMatchId == proseMessage.id,
|
onSaveAttachment = onSaveAttachment,
|
||||||
showReasoning = !hoistedReasoning,
|
savingAttachmentPath = savingAttachmentPath,
|
||||||
onOpenAttachment = viewModel::openAttachment,
|
openingAttachmentPath = openingAttachmentPath,
|
||||||
onSaveAttachment = onSaveAttachment,
|
canSaveAttachment = savingAttachmentPath == null,
|
||||||
savingAttachmentPath = savingAttachmentPath,
|
onImageClick = onImageClick,
|
||||||
openingAttachmentPath = openingAttachmentPath,
|
)
|
||||||
canSaveAttachment = savingAttachmentPath == null,
|
}
|
||||||
onImageClick = onImageClick,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
firstProseSeen = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is AgentEntry.ToolRow -> {
|
is AgentEntry.ToolRow -> {
|
||||||
@@ -298,6 +292,12 @@ fun FullBleedChatList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
agentStatus?.let { status ->
|
||||||
|
item(key = "agent_status") {
|
||||||
|
AgentStatusIndicator(status = status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Clarify bubble — rendered at the very bottom
|
// Clarify bubble — rendered at the very bottom
|
||||||
if (clarifyRequest != null) {
|
if (clarifyRequest != null) {
|
||||||
item(key = "clarify_bubble") {
|
item(key = "clarify_bubble") {
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ import kotlinx.coroutines.delay
|
|||||||
internal fun StreamingFullBleedWithTypingEffect(
|
internal fun StreamingFullBleedWithTypingEffect(
|
||||||
streaming: ChatMessage,
|
streaming: ChatMessage,
|
||||||
typingDelayMs: Int,
|
typingDelayMs: Int,
|
||||||
isDark: Boolean,
|
|
||||||
showTurnHeader: Boolean = true,
|
|
||||||
showReasoning: Boolean = true,
|
showReasoning: Boolean = true,
|
||||||
onAnimationComplete: () -> Unit = {},
|
onAnimationComplete: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
@@ -86,9 +84,7 @@ internal fun StreamingFullBleedWithTypingEffect(
|
|||||||
|
|
||||||
FullBleedAgentMessage(
|
FullBleedAgentMessage(
|
||||||
message = streaming.copy(content = displayText),
|
message = streaming.copy(content = displayText),
|
||||||
showTurnHeader = showTurnHeader,
|
|
||||||
showReasoning = showReasoning,
|
showReasoning = showReasoning,
|
||||||
isDarkTheme = isDark,
|
|
||||||
searchQuery = "",
|
searchQuery = "",
|
||||||
isCurrentMatch = false,
|
isCurrentMatch = false,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ sealed interface AgentEntry {
|
|||||||
) : AgentEntry
|
) : AgentEntry
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Empty assistant placeholders carry no user-visible prose and need no list item. */
|
||||||
|
internal fun ChatMessage.hasVisibleAgentContent(): Boolean = content.isNotBlank() || !attachments.isNullOrEmpty()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stable content prefix the backend uses for its max-iterations runtime nudge
|
* Stable content prefix the backend uses for its max-iterations runtime nudge
|
||||||
* (`handle_max_iterations` in run_agent.py → chat_completion_helpers.py, text
|
* (`handle_max_iterations` in run_agent.py → chat_completion_helpers.py, text
|
||||||
@@ -145,15 +148,16 @@ fun groupIntoTurnsWithStreaming(
|
|||||||
* full-bleed renderer.
|
* full-bleed renderer.
|
||||||
*
|
*
|
||||||
* The lazy list does NOT have one item per message: each agent turn with a
|
* The lazy list does NOT have one item per message: each agent turn with a
|
||||||
* reasoning block emits an extra `reasoning-<id>` item BEFORE its prose, and
|
* reasoning block emits an extra `reasoning-<id>` item BEFORE its prose, empty
|
||||||
* tool rows / system events are items too. Scrolling a search match by raw
|
* assistant placeholders emit no prose item, and tool rows / system events are
|
||||||
* message index therefore lands on the WRONG item whenever reasoning or tool
|
* items too. Scrolling a search match by raw message index therefore lands on
|
||||||
* rows precede the target — the classic "match is above/below the view" bug.
|
* the WRONG item whenever reasoning or tool rows precede the target — the
|
||||||
|
* classic "match is above/below the view" bug.
|
||||||
*
|
*
|
||||||
* Mirrors the item emission order in [FullBleedChatList] exactly:
|
* Mirrors the item emission order in [FullBleedChatList] exactly:
|
||||||
* user turn → 1 item; agent turn → optional reasoning item, then one item per
|
* user turn → 1 item; agent turn → optional reasoning item, then one item per
|
||||||
* entry. [leadingItems] accounts for fixed items emitted before the turns
|
* visible entry. [leadingItems] accounts for fixed items emitted before the
|
||||||
* (e.g. the `loading-older` spinner when paging).
|
* turns (e.g. the `loading-older` spinner when paging).
|
||||||
*
|
*
|
||||||
* @return messageId → LazyColumn item index of the message's content item
|
* @return messageId → LazyColumn item index of the message's content item
|
||||||
* (prose items for agent messages; user items for user messages).
|
* (prose items for agent messages; user items for user messages).
|
||||||
@@ -183,8 +187,10 @@ fun messageIdToLazyIndex(
|
|||||||
turn.entries.forEach { entry ->
|
turn.entries.forEach { entry ->
|
||||||
when (entry) {
|
when (entry) {
|
||||||
is AgentEntry.Prose -> {
|
is AgentEntry.Prose -> {
|
||||||
map[entry.message.id] = itemIndex
|
if (entry.message.hasVisibleAgentContent()) {
|
||||||
itemIndex++
|
map[entry.message.id] = itemIndex
|
||||||
|
itemIndex++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is AgentEntry.ToolRow -> {
|
is AgentEntry.ToolRow -> {
|
||||||
|
|||||||
@@ -184,7 +184,13 @@
|
|||||||
<string name="settings_sec_chat">المحادثة</string>
|
<string name="settings_sec_chat">المحادثة</string>
|
||||||
<string name="settings_item_typing_effect">تأثير الكتابة</string>
|
<string name="settings_item_typing_effect">تأثير الكتابة</string>
|
||||||
<string name="settings_desc_typing_effect">تحريك ظهور الرسائل الجديدة كلمة بكلمة</string>
|
<string name="settings_desc_typing_effect">تحريك ظهور الرسائل الجديدة كلمة بكلمة</string>
|
||||||
<string name="fullbleed_role_agent">المساعد</string>
|
<string name="chat_agent_status_typing">الوكيل يكتب</string>
|
||||||
|
<string name="chat_agent_status_thinking">يفكّر…</string>
|
||||||
|
<string name="chat_agent_status_searching">يبحث…</string>
|
||||||
|
<string name="chat_agent_status_reading">يقرأ الملف…</string>
|
||||||
|
<string name="chat_agent_status_browsing">يتصفّح…</string>
|
||||||
|
<string name="chat_agent_status_running_command">يشغّل الأمر…</string>
|
||||||
|
<string name="chat_agent_status_using_tool">يستخدم أداة…</string>
|
||||||
<string name="settings_item_typing_delay">التأخير: %1$d مللي ثانية</string>
|
<string name="settings_item_typing_delay">التأخير: %1$d مللي ثانية</string>
|
||||||
<string name="settings_delay_10ms">10 مللي ثانية</string>
|
<string name="settings_delay_10ms">10 مللي ثانية</string>
|
||||||
<string name="settings_delay_100ms">100 مللي ثانية</string>
|
<string name="settings_delay_100ms">100 مللي ثانية</string>
|
||||||
|
|||||||
@@ -187,7 +187,13 @@
|
|||||||
<string name="settings_sec_chat">채팅</string>
|
<string name="settings_sec_chat">채팅</string>
|
||||||
<string name="settings_item_typing_effect">타이핑 효과</string>
|
<string name="settings_item_typing_effect">타이핑 효과</string>
|
||||||
<string name="settings_desc_typing_effect">새 메시지를 단어 단위로 애니메이션</string>
|
<string name="settings_desc_typing_effect">새 메시지를 단어 단위로 애니메이션</string>
|
||||||
<string name="fullbleed_role_agent">어시스턴트</string>
|
<string name="chat_agent_status_typing">에이전트 입력 중</string>
|
||||||
|
<string name="chat_agent_status_thinking">생각 중…</string>
|
||||||
|
<string name="chat_agent_status_searching">검색 중…</string>
|
||||||
|
<string name="chat_agent_status_reading">파일 읽는 중…</string>
|
||||||
|
<string name="chat_agent_status_browsing">탐색 중…</string>
|
||||||
|
<string name="chat_agent_status_running_command">명령 실행 중…</string>
|
||||||
|
<string name="chat_agent_status_using_tool">도구 사용 중…</string>
|
||||||
<string name="settings_item_typing_delay">지연: %1$d ms</string>
|
<string name="settings_item_typing_delay">지연: %1$d ms</string>
|
||||||
<string name="settings_delay_10ms">10 ms</string>
|
<string name="settings_delay_10ms">10 ms</string>
|
||||||
<string name="settings_delay_100ms">100 ms</string>
|
<string name="settings_delay_100ms">100 ms</string>
|
||||||
|
|||||||
@@ -184,7 +184,13 @@
|
|||||||
<string name="settings_sec_chat">对话</string>
|
<string name="settings_sec_chat">对话</string>
|
||||||
<string name="settings_item_typing_effect">打字效果</string>
|
<string name="settings_item_typing_effect">打字效果</string>
|
||||||
<string name="settings_desc_typing_effect">逐字显示新消息动画</string>
|
<string name="settings_desc_typing_effect">逐字显示新消息动画</string>
|
||||||
<string name="fullbleed_role_agent">助手</string>
|
<string name="chat_agent_status_typing">助手正在输入</string>
|
||||||
|
<string name="chat_agent_status_thinking">正在思考…</string>
|
||||||
|
<string name="chat_agent_status_searching">正在搜索…</string>
|
||||||
|
<string name="chat_agent_status_reading">正在读取文件…</string>
|
||||||
|
<string name="chat_agent_status_browsing">正在浏览…</string>
|
||||||
|
<string name="chat_agent_status_running_command">正在运行命令…</string>
|
||||||
|
<string name="chat_agent_status_using_tool">正在使用工具…</string>
|
||||||
<string name="settings_item_typing_delay">延迟:%1$d 毫秒</string>
|
<string name="settings_item_typing_delay">延迟:%1$d 毫秒</string>
|
||||||
<string name="settings_delay_10ms">10 毫秒</string>
|
<string name="settings_delay_10ms">10 毫秒</string>
|
||||||
<string name="settings_delay_100ms">100 毫秒</string>
|
<string name="settings_delay_100ms">100 毫秒</string>
|
||||||
|
|||||||
@@ -208,7 +208,13 @@
|
|||||||
<string name="settings_sec_chat">Chat</string>
|
<string name="settings_sec_chat">Chat</string>
|
||||||
<string name="settings_item_typing_effect">Typing Effect</string>
|
<string name="settings_item_typing_effect">Typing Effect</string>
|
||||||
<string name="settings_desc_typing_effect">Animate new messages word by word</string>
|
<string name="settings_desc_typing_effect">Animate new messages word by word</string>
|
||||||
<string name="fullbleed_role_agent">Assistant</string>
|
<string name="chat_agent_status_typing">Agent is typing</string>
|
||||||
|
<string name="chat_agent_status_thinking">Thinking…</string>
|
||||||
|
<string name="chat_agent_status_searching">Searching…</string>
|
||||||
|
<string name="chat_agent_status_reading">Reading file…</string>
|
||||||
|
<string name="chat_agent_status_browsing">Browsing…</string>
|
||||||
|
<string name="chat_agent_status_running_command">Running command…</string>
|
||||||
|
<string name="chat_agent_status_using_tool">Using tool…</string>
|
||||||
<string name="settings_item_typing_delay">Delay: %1$d ms</string>
|
<string name="settings_item_typing_delay">Delay: %1$d ms</string>
|
||||||
<string name="settings_delay_10ms">10 ms</string>
|
<string name="settings_delay_10ms">10 ms</string>
|
||||||
<string name="settings_delay_100ms">100 ms</string>
|
<string name="settings_delay_100ms">100 ms</string>
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
package com.m57.hermescontrol.ui.chat
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.ui.chat.fullbleed.AgentEntry
|
import com.m57.hermescontrol.ui.chat.fullbleed.AgentEntry
|
||||||
|
import com.m57.hermescontrol.ui.chat.fullbleed.AgentStatus
|
||||||
import com.m57.hermescontrol.ui.chat.fullbleed.ChatTurn
|
import com.m57.hermescontrol.ui.chat.fullbleed.ChatTurn
|
||||||
import com.m57.hermescontrol.ui.chat.fullbleed.currentMatchMessageId
|
import com.m57.hermescontrol.ui.chat.fullbleed.currentMatchMessageId
|
||||||
|
import com.m57.hermescontrol.ui.chat.fullbleed.deriveAgentStatus
|
||||||
import com.m57.hermescontrol.ui.chat.fullbleed.groupIntoTurns
|
import com.m57.hermescontrol.ui.chat.fullbleed.groupIntoTurns
|
||||||
import com.m57.hermescontrol.ui.chat.fullbleed.groupIntoTurnsWithStreaming
|
import com.m57.hermescontrol.ui.chat.fullbleed.groupIntoTurnsWithStreaming
|
||||||
import com.m57.hermescontrol.ui.chat.fullbleed.matchedMessageIds
|
import com.m57.hermescontrol.ui.chat.fullbleed.matchedMessageIds
|
||||||
import com.m57.hermescontrol.ui.chat.fullbleed.messageIdToLazyIndex
|
import com.m57.hermescontrol.ui.chat.fullbleed.messageIdToLazyIndex
|
||||||
|
import com.m57.hermescontrol.ui.chat.fullbleed.toolStatusLabelRes
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -208,6 +212,167 @@ class FullBleedTurnsTest {
|
|||||||
assertEquals(listOf(entries(AgentEntry.Prose(s))), groupIntoTurnsWithStreaming(emptyList(), s))
|
assertEquals(listOf(entries(AgentEntry.Prose(s))), groupIntoTurnsWithStreaming(emptyList(), s))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── deriveAgentStatus ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `agent typing with no visible content shows dots`() {
|
||||||
|
assertEquals(
|
||||||
|
AgentStatus.Typing,
|
||||||
|
deriveAgentStatus(
|
||||||
|
isAgentTyping = true,
|
||||||
|
streamingState = StreamingState(),
|
||||||
|
messages = emptyList(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reasoning takes precedence over generic typing`() {
|
||||||
|
assertEquals(
|
||||||
|
AgentStatus.Thinking,
|
||||||
|
deriveAgentStatus(
|
||||||
|
isAgentTyping = true,
|
||||||
|
streamingState = StreamingState(isReasoning = true),
|
||||||
|
messages = emptyList(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `visible streaming content removes the temporary status`() {
|
||||||
|
val streaming = msg("s1", MessageRole.ASSISTANT, content = "answer", isStreaming = true)
|
||||||
|
assertNull(
|
||||||
|
deriveAgentStatus(
|
||||||
|
isAgentTyping = true,
|
||||||
|
streamingState = StreamingState(streamingMessage = streaming, isThinking = true),
|
||||||
|
messages = emptyList(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `stale running tool from a previous turn is ignored`() {
|
||||||
|
val messages =
|
||||||
|
listOf(
|
||||||
|
msg("old-user", MessageRole.USER),
|
||||||
|
msg("old-tool", MessageRole.TOOL, toolStatus = ToolStatus.RUNNING).copy(toolName = "terminal"),
|
||||||
|
msg("old-assistant", MessageRole.ASSISTANT, content = "previous response"),
|
||||||
|
msg("new-user", MessageRole.USER),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
AgentStatus.Typing,
|
||||||
|
deriveAgentStatus(
|
||||||
|
isAgentTyping = true,
|
||||||
|
streamingState = StreamingState(),
|
||||||
|
messages = messages,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `current turn running tool is detected`() {
|
||||||
|
val messages =
|
||||||
|
listOf(
|
||||||
|
msg("old-user", MessageRole.USER),
|
||||||
|
msg("old-tool", MessageRole.TOOL, toolStatus = ToolStatus.COMPLETED).copy(toolName = "terminal"),
|
||||||
|
msg("old-assistant", MessageRole.ASSISTANT, content = "previous response"),
|
||||||
|
msg("new-user", MessageRole.USER),
|
||||||
|
msg("current-tool", MessageRole.TOOL, toolStatus = ToolStatus.RUNNING).copy(toolName = "web_search"),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
AgentStatus.Tool("web_search"),
|
||||||
|
deriveAgentStatus(
|
||||||
|
isAgentTyping = true,
|
||||||
|
streamingState = StreamingState(),
|
||||||
|
messages = messages,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `latest current turn running tool wins`() {
|
||||||
|
val messages =
|
||||||
|
listOf(
|
||||||
|
msg("new-user", MessageRole.USER),
|
||||||
|
msg("done-tool", MessageRole.TOOL, toolStatus = ToolStatus.COMPLETED).copy(toolName = "terminal"),
|
||||||
|
msg("first-running", MessageRole.TOOL, toolStatus = ToolStatus.RUNNING).copy(toolName = "read_file"),
|
||||||
|
msg("latest-running", MessageRole.TOOL, toolStatus = ToolStatus.RUNNING).copy(toolName = "web_search"),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
AgentStatus.Tool("web_search"),
|
||||||
|
deriveAgentStatus(
|
||||||
|
isAgentTyping = true,
|
||||||
|
streamingState = StreamingState(),
|
||||||
|
messages = messages,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `completed current turn tool falls back to dots`() {
|
||||||
|
val messages =
|
||||||
|
listOf(
|
||||||
|
msg("new-user", MessageRole.USER),
|
||||||
|
msg(
|
||||||
|
"completed-tool",
|
||||||
|
MessageRole.TOOL,
|
||||||
|
toolStatus = ToolStatus.COMPLETED,
|
||||||
|
).copy(toolName = "web_search"),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
AgentStatus.Typing,
|
||||||
|
deriveAgentStatus(
|
||||||
|
isAgentTyping = true,
|
||||||
|
streamingState = StreamingState(),
|
||||||
|
messages = messages,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `timeline marker does not create a new tool-status turn`() {
|
||||||
|
val marker =
|
||||||
|
msg("marker", MessageRole.USER, content = "model changed")
|
||||||
|
.copy(displayKind = "model_switch")
|
||||||
|
val messages =
|
||||||
|
listOf(
|
||||||
|
msg("new-user", MessageRole.USER),
|
||||||
|
marker,
|
||||||
|
msg("current-tool", MessageRole.TOOL, toolStatus = ToolStatus.RUNNING).copy(toolName = "web_search"),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
AgentStatus.Tool("web_search"),
|
||||||
|
deriveAgentStatus(
|
||||||
|
isAgentTyping = true,
|
||||||
|
streamingState = StreamingState(),
|
||||||
|
messages = messages,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `browser tools use browsing label while read_file keeps file label`() {
|
||||||
|
assertEquals(R.string.chat_agent_status_browsing, toolStatusLabelRes("browser_navigate"))
|
||||||
|
assertEquals(R.string.chat_agent_status_browsing, toolStatusLabelRes("web_extract"))
|
||||||
|
assertEquals(R.string.chat_agent_status_reading, toolStatusLabelRes("read_file"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `inactive generation ignores even a running historical tool`() {
|
||||||
|
val messages =
|
||||||
|
listOf(
|
||||||
|
msg("old-user", MessageRole.USER),
|
||||||
|
msg("old-tool", MessageRole.TOOL, toolStatus = ToolStatus.RUNNING).copy(toolName = "terminal"),
|
||||||
|
)
|
||||||
|
assertNull(
|
||||||
|
deriveAgentStatus(
|
||||||
|
isAgentTyping = false,
|
||||||
|
streamingState = StreamingState(),
|
||||||
|
messages = messages,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── messageIdToLazyIndex ───────────────────────────────────────────────
|
// ── messageIdToLazyIndex ───────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -233,6 +398,19 @@ class FullBleedTurnsTest {
|
|||||||
assertNull(map["t1"])
|
assertNull(map["t1"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `lazy index skips empty assistant placeholders`() {
|
||||||
|
val u1 = msg("u1", MessageRole.USER)
|
||||||
|
val placeholder = msg("empty", MessageRole.ASSISTANT, content = "", isStreaming = true)
|
||||||
|
val tool = msg("t1", MessageRole.TOOL)
|
||||||
|
val answer = msg("a1", MessageRole.ASSISTANT, content = "answer")
|
||||||
|
val map = messageIdToLazyIndex(groupIntoTurns(listOf(u1, placeholder, tool, answer)))
|
||||||
|
// user u1 at 0, empty assistant omitted, tool at 1, answer at 2.
|
||||||
|
assertEquals(0, map["u1"])
|
||||||
|
assertEquals(2, map["a1"])
|
||||||
|
assertNull(map["empty"])
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `lazy index offsets by leading items`() {
|
fun `lazy index offsets by leading items`() {
|
||||||
val u1 = msg("u1", MessageRole.USER)
|
val u1 = msg("u1", MessageRole.USER)
|
||||||
|
|||||||
Reference in new issue
Block a user