feat(chat): simplify assistant message states (#1131)

This commit is contained in:
M57 authored and GitHub committed 2026-09-15 21:43:20 +03:00
1 parent e6b441cfc6
commit 984053b0e7
15 files changed
+552 -201

No files matched your search

@@ -1,12 +1,16 @@
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.AgentStatus
import com.m57.hermescontrol.ui.chat.fullbleed.ChatTurn
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.groupIntoTurnsWithStreaming
import com.m57.hermescontrol.ui.chat.fullbleed.matchedMessageIds
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.assertNull
import org.junit.Test
@@ -208,6 +212,167 @@ class FullBleedTurnsTest {
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 ───────────────────────────────────────────────
@Test
@@ -233,6 +398,19 @@ class FullBleedTurnsTest {
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
fun `lazy index offsets by leading items`() {
val u1 = msg("u1", MessageRole.USER)