feat(chat): simplify assistant message states (#1131)
This commit is contained in:
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)
|
||||
|
||||
Reference in new issue
Block a user