chore: promote dev to main (#1455)

Co-authored-by: AlexDev_ <56083016+alexdev03@users.noreply.github.com>
This commit is contained in:
m57andAlexDev_ authored and GitHub committed 2026-10-03 00:51:11 +03:00
1 parent 62fcf07e1b
commit 944390bec1
56 files changed
+3914 -304

No files matched your search

@@ -0,0 +1,233 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* Regression tests for SessionMessage multipart content and display_content text extraction.
*
* Contract requirements (Issue #1432):
* - [SessionMessage.contentText] and [SessionMessage.displayContentText] extract recognized text parts
* of multipart JsonArray in order.
* - Recognized text parts: {"type": "text", "text": "..."} or {"text": "..."}.
* - Omit "image_url", inline base64 ("data:image/..."), and unknown/malformed parts rather than JSON dumping.
* - Null content -> empty string ("").
* - Absent display_content (null) -> null.
* - Explicit empty string display wins ("" -> "").
* - Plain string primitives are retained unchanged (including very long plain text).
* - Avoid imposing changes on unknown object tool content (non-array objects dump or retain their object representation).
*/
class SessionMessageMultipartTest {
@Test
fun contentText_whenContentIsNull_returnsEmptyString() {
val message = SessionMessage(content = null)
assertEquals("", message.contentText)
}
@Test
fun displayContentText_whenDisplayContentIsNull_returnsNull() {
val message = SessionMessage(display_content = null)
assertNull(message.displayContentText)
}
@Test
fun displayContentText_whenDisplayContentIsExplicitEmptyString_returnsEmptyString() {
val message = SessionMessage(display_content = JsonPrimitive(""))
assertEquals("", message.displayContentText)
}
@Test
fun contentText_whenContentIsPrimitiveString_retainsUnchanged() {
val text = "Hello world, this is a plain message turn."
val message = SessionMessage(content = JsonPrimitive(text))
assertEquals(text, message.contentText)
}
@Test
fun contentText_whenContentIsLongPlainText_retainsEntireStringUnchanged() {
val longText = "A".repeat(100_000)
val message = SessionMessage(content = JsonPrimitive(longText))
assertEquals(longText, message.contentText)
}
@Test
fun contentText_whenMultipartWith748kBase64AndImageCaption_extractsCaptionOnly() {
val largeBase64 = "data:image/png;base64," + "iVBORw0KGgoAAAANSUhEUgAA".repeat(31_000) // ~748k chars
val caption = "Check out this screenshot\n@image:/opt/data/images/screen.png"
val multipart =
buildJsonArray {
add(
buildJsonObject {
put("type", "image_url")
putJsonObject("image_url") {
put("url", largeBase64)
}
},
)
add(
buildJsonObject {
put("type", "text")
put("text", caption)
},
)
}
val message = SessionMessage(content = multipart)
assertEquals(caption, message.contentText)
}
@Test
fun contentText_whenMultipleTextPartsInArray_extractsInOrder() {
val multipart =
buildJsonArray {
add(
buildJsonObject {
put("type", "text")
put("text", "First paragraph.")
},
)
add(
buildJsonObject {
put("type", "image_url")
putJsonObject("image_url") {
put("url", "https://example.com/photo.png")
}
},
)
add(
buildJsonObject {
put("type", "text")
put("text", "Second paragraph.")
},
)
add(
buildJsonObject {
// Shorthand text block without explicit type
put("text", "Third paragraph.")
},
)
}
val message = SessionMessage(content = multipart)
val expected = "First paragraph.\nSecond paragraph.\nThird paragraph."
assertEquals(expected, message.contentText)
}
@Test
fun contentText_whenMultipartContainsMalformedOrUnknownElements_omitsThemWithoutJsonDumping() {
val multipart =
buildJsonArray {
add(JsonPrimitive(12345))
add(JsonPrimitive(true))
add(
buildJsonObject {
put("type", "unknown_blob")
put("raw", "skip_me")
},
)
add(
buildJsonObject {
put("type", "text")
put("invalid_key", "no text property here")
},
)
add(
buildJsonObject {
put("type", "text")
put("text", "Valid extracted text.")
},
)
add(
buildJsonObject {
put("type", "image")
put("source", "base64data")
},
)
}
val message = SessionMessage(content = multipart)
assertEquals("Valid extracted text.", message.contentText)
}
@Test
fun contentText_whenMultipartHasNoTextParts_returnsEmptyString() {
val multipart =
buildJsonArray {
add(
buildJsonObject {
put("type", "image_url")
putJsonObject("image_url") {
put("url", "https://example.com/icon.png")
}
},
)
add(
buildJsonObject {
put("type", "audio")
put("data", "abc123audio")
},
)
}
val message = SessionMessage(content = multipart)
assertEquals("", message.contentText)
}
@Test
fun displayContentText_whenProjectedDisplayContentIsMultipartArray_extractsTextParts() {
val projectedDisplayArray =
buildJsonArray {
add(
buildJsonObject {
put("type", "text")
put("text", "Summary of conversation")
},
)
add(
buildJsonObject {
put("type", "image_url")
putJsonObject("image_url") {
put("url", "data:image/jpeg;base64,12345")
}
},
)
add(
buildJsonObject {
put("type", "text")
put("text", "Key takeaways follow.")
},
)
}
val message =
SessionMessage(
content = JsonPrimitive("raw fallback content"),
display_content = projectedDisplayArray,
)
assertEquals("Summary of conversation\nKey takeaways follow.", message.displayContentText)
}
@Test
fun contentText_whenContentIsJsonObjectToolCallOrResult_doesNotImposeMultipartChanges() {
val toolObject =
buildJsonObject {
put("status", "success")
put("output", "tool execution output")
}
val message = SessionMessage(content = toolObject)
// Ensure non-array JsonObject continues existing behavior (e.g. toString/not mangled as multipart array)
assertEquals(toolObject.toString(), message.contentText)
}
}
@@ -71,14 +71,20 @@ class GatewayContractFramesTest {
@After
fun tearDown() {
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.disconnect(clearPendingMessages = true)
runBlocking {
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
try {
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.disconnect(clearPendingMessages = true)
runBlocking {
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
}
} finally {
try {
mockWebServer.shutdown()
} finally {
unmockkAll()
}
}
runCatching { mockWebServer.shutdown() }
unmockkAll()
}
/** Connect, run [trigger], and return the first frame the server sees for [method]. */
@@ -91,7 +97,7 @@ class GatewayContractFramesTest {
val captured = CountDownLatch(1)
val frame = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
@@ -105,7 +105,7 @@ class GatewayContractTest {
// ChatViewModel, GroupChatViewModel
"session.create" to setOf("profile", "title", "source", "hidden"),
// ChatViewModel.resumeSession, NotificationReplyReceiver
"session.resume" to setOf("session_id", "omit_messages"),
"session.resume" to setOf("session_id", "source", "omit_messages"),
// ChatViewModel, GroupChatViewModel
"session.interrupt" to setOf("session_id"),
)
@@ -76,15 +76,21 @@ class HermesWsClientSessionControlTest {
@After
fun tearDown() {
AuthManager.resetAuthStateForTest()
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.disconnect(clearPendingMessages = true)
runBlocking {
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
try {
AuthManager.resetAuthStateForTest()
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.disconnect(clearPendingMessages = true)
runBlocking {
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
}
} finally {
try {
mockWebServer.shutdown()
} finally {
unmockkAll()
}
}
runCatching { mockWebServer.shutdown() }
unmockkAll()
}
private fun connectClient() {
@@ -101,7 +107,7 @@ class HermesWsClientSessionControlTest {
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
@@ -163,7 +169,7 @@ class HermesWsClientSessionControlTest {
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
@@ -224,7 +230,7 @@ class HermesWsClientSessionControlTest {
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
@@ -104,18 +104,17 @@ class HermesWsClientTest {
@After
fun tearDown() {
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.disconnect(clearPendingMessages = true)
// Wait a bit to allow internal OkHttp coroutines to clean up before shutting down MockWebServer
// Increased from 100ms for OkHttp 5.x — needs more time for the WS close handshake
Thread.sleep(500)
try {
mockWebServer.shutdown()
} catch (e: Throwable) {
e.printStackTrace()
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.disconnect(clearPendingMessages = true)
} finally {
try {
mockWebServer.shutdown()
} finally {
unmockkAll()
}
}
unmockkAll()
}
@Test
@@ -126,7 +125,7 @@ class HermesWsClientTest {
var receivedMessage: String? = null
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
@@ -171,7 +170,7 @@ class HermesWsClientTest {
var capabilityFrame: String? = null
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
@@ -221,7 +220,7 @@ class HermesWsClientTest {
val capabilityResponseCount = AtomicInteger(0)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
@@ -301,7 +300,7 @@ class HermesWsClientTest {
fun testOpenRequestsReplayUsesTheLiveServerRequestDispatcher() =
runBlocking {
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
@@ -759,7 +758,7 @@ class HermesWsClientTest {
HermesWsClient.connectedForTest.set(true)
val oldSocket = mockk<WebSocket>(relaxed = true)
HermesWsClient.webSocketForTest = oldSocket
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {}))
mockWebServer.enqueue(MockResponse().withClosingWebSocketUpgrade(object : WebSocketListener() {}))
HermesWsClient.connect()
@@ -789,7 +788,7 @@ class HermesWsClientTest {
assertTrue(refreshStarted.await(5, TimeUnit.SECONDS))
HermesWsClient.disconnect(clearPendingMessages = true)
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {}))
mockWebServer.enqueue(MockResponse().withClosingWebSocketUpgrade(object : WebSocketListener() {}))
val disconnectedGeneration = HermesWsClient.connectionGenerationForTest.get()
val replacementConnect = thread { HermesWsClient.connect() }
val generationDeadline = System.currentTimeMillis() + 5_000
@@ -821,7 +820,7 @@ class HermesWsClientTest {
val serverLatch = CountDownLatch(1)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
@@ -862,7 +861,7 @@ class HermesWsClientTest {
@Test
fun testBackgroundWithoutPendingWorkDisconnects() {
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {}))
mockWebServer.enqueue(MockResponse().withClosingWebSocketUpgrade(object : WebSocketListener() {}))
HermesWsClient.connect()
runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } }
@@ -874,7 +873,7 @@ class HermesWsClientTest {
@Test
fun testExternalActivityLeaseKeepsIdleBackgroundSocketUntilReleased() {
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {}))
mockWebServer.enqueue(MockResponse().withClosingWebSocketUpgrade(object : WebSocketListener() {}))
HermesWsClient.connect()
runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } }
@@ -891,7 +890,7 @@ class HermesWsClientTest {
@Test
fun testBackgroundConnectionLeaseKeepsIdleBackgroundSocketUntilReleased() {
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {}))
mockWebServer.enqueue(MockResponse().withClosingWebSocketUpgrade(object : WebSocketListener() {}))
HermesWsClient.connect()
runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } }
@@ -908,7 +907,7 @@ class HermesWsClientTest {
@Test
fun testBackgroundWithPendingReplyStaysConnected() {
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {}))
mockWebServer.enqueue(MockResponse().withClosingWebSocketUpgrade(object : WebSocketListener() {}))
HermesWsClient.connect()
runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } }
HermesWsClient.sendMessage("session-1", "hello")
@@ -923,7 +922,7 @@ class HermesWsClientTest {
lateinit var serverSocket: WebSocket
val connectedLatch = CountDownLatch(1)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
@@ -958,7 +957,7 @@ class HermesWsClientTest {
fun testBackgroundQueuedSendDisconnectsAfterFlush() {
val messageLatch = CountDownLatch(1)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onMessage(
webSocket: WebSocket,
@@ -987,7 +986,7 @@ class HermesWsClientTest {
lateinit var serverSocket: WebSocket
val connectedLatch = CountDownLatch(1)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
@@ -1033,7 +1032,7 @@ class HermesWsClientTest {
val closedLatch = CountDownLatch(1)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
@@ -1073,7 +1072,7 @@ class HermesWsClientTest {
var receivedMessage: String? = null
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
@@ -1128,7 +1127,7 @@ class HermesWsClientTest {
val connect2Latch = CountDownLatch(1)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
@@ -1142,7 +1141,7 @@ class HermesWsClientTest {
)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
@@ -1206,7 +1205,7 @@ class HermesWsClientTest {
val serverLatch = CountDownLatch(1)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
ws: WebSocket,
@@ -1235,7 +1234,7 @@ class HermesWsClientTest {
val serverLatch = CountDownLatch(1)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
ws: WebSocket,
@@ -1265,7 +1264,7 @@ class HermesWsClientTest {
fun testDoubleConnect_ignoresSecondCallWhenConnected() {
val serverLatch = CountDownLatch(1)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
ws: WebSocket,
@@ -1291,7 +1290,7 @@ class HermesWsClientTest {
fun testStatusTransitionOnConnect() {
val serverLatch = CountDownLatch(1)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
ws: WebSocket,
@@ -1328,7 +1327,7 @@ class HermesWsClientTest {
val connectLatch = CountDownLatch(1)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
ws: WebSocket,
@@ -1342,7 +1341,7 @@ class HermesWsClientTest {
// Enqueue a second response for reconnect attempt
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
ws: WebSocket,
@@ -1400,7 +1399,7 @@ class HermesWsClientTest {
val connectLatch = CountDownLatch(1)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
ws: WebSocket,
@@ -1537,7 +1536,7 @@ class HermesWsClientTest {
var serverWebSocket: WebSocket? = null
val serverLatch = CountDownLatch(1)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
@@ -1589,7 +1588,7 @@ class HermesWsClientTest {
var serverWebSocket: WebSocket? = null
val serverLatch = CountDownLatch(1)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
@@ -1664,7 +1663,7 @@ class HermesWsClientTest {
var serverWebSocket: WebSocket? = null
val serverLatch = CountDownLatch(1)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
@@ -1719,7 +1718,7 @@ class HermesWsClientTest {
}
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
@@ -1780,7 +1779,7 @@ class HermesWsClientTest {
val socket = mockk<WebSocket>(relaxed = true)
HermesWsClient.intentionalCloseForTest.set(false)
val listener = HermesWsClient.createListenerForTest(HermesWsClient.connectionGenerationForTest.get())
// Directly install the reflection-built listener so onMessage runs even
// Directly install the transport listener so onMessage runs even
// though this test never performed a real OkHttp connect.
HermesWsClient.webSocketForTest = socket
mockkObject(EventParser)
@@ -1826,7 +1825,7 @@ class HermesWsClientTest {
}
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
@@ -1888,7 +1887,7 @@ class HermesWsClientTest {
var pingReceived = false
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
@@ -1971,7 +1970,7 @@ class HermesWsClientTest {
val serverLatch = CountDownLatch(1)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
@@ -103,15 +103,21 @@ class HermesWsClientTypedCallTest {
@After
fun tearDown() {
AuthManager.resetAuthStateForTest()
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.disconnect(clearPendingMessages = true)
runBlocking {
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
try {
AuthManager.resetAuthStateForTest()
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.disconnect(clearPendingMessages = true)
runBlocking {
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
}
} finally {
try {
mockWebServer.shutdown()
} finally {
unmockkAll()
}
}
runCatching { mockWebServer.shutdown() }
unmockkAll()
}
private fun connectClient() {
@@ -128,7 +134,7 @@ class HermesWsClientTypedCallTest {
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
@@ -190,7 +196,7 @@ class HermesWsClientTypedCallTest {
val opened = CountDownLatch(1)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
@@ -233,7 +239,7 @@ class HermesWsClientTypedCallTest {
val opened = CountDownLatch(1)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
@@ -281,7 +287,7 @@ class HermesWsClientTypedCallTest {
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
@@ -332,7 +338,7 @@ class HermesWsClientTypedCallTest {
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
@@ -382,7 +388,7 @@ class HermesWsClientTypedCallTest {
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
@@ -429,7 +435,7 @@ class HermesWsClientTypedCallTest {
val opened = CountDownLatch(1)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
@@ -475,7 +481,7 @@ class HermesWsClientTypedCallTest {
val requestReceived = CountDownLatch(1)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
@@ -77,15 +77,21 @@ class HermesWsClientTypedSendTest {
@After
fun tearDown() {
AuthManager.resetAuthStateForTest()
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.disconnect(clearPendingMessages = true)
runBlocking {
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
try {
AuthManager.resetAuthStateForTest()
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.disconnect(clearPendingMessages = true)
runBlocking {
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
}
} finally {
try {
mockWebServer.shutdown()
} finally {
unmockkAll()
}
}
runCatching { mockWebServer.shutdown() }
unmockkAll()
}
private fun connectClient() {
@@ -102,7 +108,7 @@ class HermesWsClientTypedSendTest {
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
@@ -163,7 +169,7 @@ class HermesWsClientTypedSendTest {
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
@@ -216,7 +222,7 @@ class HermesWsClientTypedSendTest {
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
@@ -274,7 +280,7 @@ class HermesWsClientTypedSendTest {
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
@@ -325,7 +331,7 @@ class HermesWsClientTypedSendTest {
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
@@ -382,7 +388,7 @@ class HermesWsClientTypedSendTest {
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
@@ -437,7 +443,7 @@ class HermesWsClientTypedSendTest {
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
@@ -0,0 +1,56 @@
package com.m57.hermescontrol.data.ws
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.MockResponse
import okio.ByteString
/**
* Keep the test listener's behavior, but acknowledge peer close frames so MockWebServer's
* WebSocket task can finish. WebSocketListener's default onClosing is a no-op.
*/
internal fun MockResponse.withClosingWebSocketUpgrade(delegate: WebSocketListener): MockResponse =
withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = delegate.onOpen(webSocket, response)
override fun onMessage(
webSocket: WebSocket,
text: String,
) = delegate.onMessage(webSocket, text)
override fun onMessage(
webSocket: WebSocket,
bytes: ByteString,
) = delegate.onMessage(webSocket, bytes)
override fun onClosing(
webSocket: WebSocket,
code: Int,
reason: String,
) {
try {
delegate.onClosing(webSocket, code, reason)
} finally {
// The delegate may already have initiated a close; a false return is valid.
webSocket.close(code, reason)
}
}
override fun onClosed(
webSocket: WebSocket,
code: Int,
reason: String,
) = delegate.onClosed(webSocket, code, reason)
override fun onFailure(
webSocket: WebSocket,
t: Throwable,
response: Response?,
) = delegate.onFailure(webSocket, t, response)
},
)
@@ -0,0 +1,113 @@
package com.m57.hermescontrol.data.ws
import io.mockk.every
import io.mockk.mockk
import io.mockk.verifySequence
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.MockResponse
import okio.ByteString.Companion.encodeUtf8
import org.junit.Assert.assertSame
import org.junit.Assert.assertThrows
import org.junit.Test
class MockWebSocketCleanupListenerTest {
private val socket = mockk<WebSocket>()
private val delegate = mockk<WebSocketListener>(relaxed = true)
private val listener: WebSocketListener
get() = requireNotNull(MockResponse().withClosingWebSocketUpgrade(delegate).webSocketListener)
@Test
fun acknowledgesCloseAfterDelegatedCallback() {
every { socket.close(1000, "complete") } returns true
listener.onClosing(socket, 1000, "complete")
verifySequence {
delegate.onClosing(socket, 1000, "complete")
socket.close(1000, "complete")
}
}
@Test
fun acknowledgesCloseEvenWhenDelegatedCallbackThrows() {
val failure = IllegalStateException("callback failure")
every { delegate.onClosing(socket, 1000, "complete") } throws failure
every { socket.close(1000, "complete") } returns true
val thrown =
assertThrows(IllegalStateException::class.java) {
listener.onClosing(socket, 1000, "complete")
}
assertSame(failure, thrown)
verifySequence {
delegate.onClosing(socket, 1000, "complete")
socket.close(1000, "complete")
}
}
@Test
fun acceptsAlreadyInitiatedCustomCloseWithoutOverridingIt() {
every { socket.close(1001, "custom close") } returns true
every { socket.close(1000, "complete") } returns false
every { delegate.onClosing(socket, 1000, "complete") } answers {
socket.close(1001, "custom close")
Unit
}
listener.onClosing(socket, 1000, "complete")
verifySequence {
delegate.onClosing(socket, 1000, "complete")
socket.close(1001, "custom close")
socket.close(1000, "complete")
}
}
@Test
fun forwardsOpenWithOriginalSocketAndResponse() {
val response = mockk<Response>()
listener.onOpen(socket, response)
verifySequence { delegate.onOpen(socket, response) }
}
@Test
fun forwardsBothTextAndBinaryMessages() {
val bytes = "binary message".encodeUtf8()
val wrapped = listener
wrapped.onMessage(socket, "text message")
wrapped.onMessage(socket, bytes)
verifySequence {
delegate.onMessage(socket, "text message")
delegate.onMessage(socket, bytes)
}
}
@Test
fun forwardsClosedWithOriginalCodeAndReason() {
listener.onClosed(socket, 1001, "server close")
verifySequence { delegate.onClosed(socket, 1001, "server close") }
}
@Test
fun forwardsFailureWithOriginalThrowableAndNullableResponse() {
val failure = IllegalStateException("transport failure")
val response = mockk<Response>()
val wrapped = listener
wrapped.onFailure(socket, failure, response)
wrapped.onFailure(socket, failure, null)
verifySequence {
delegate.onFailure(socket, failure, response)
delegate.onFailure(socket, failure, null)
}
}
}
@@ -0,0 +1,104 @@
package com.m57.hermescontrol.data.ws
import android.util.Log
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
class MockWebSocketCleanupTest {
@Before
fun setUp() {
mockkStatic(Log::class)
every { Log.isLoggable(any(), any()) } returns false
every { Log.println(any(), any(), any()) } returns 0
}
@After
fun tearDown() {
unmockkAll()
}
@Test
fun peerCloseAllowsMockServerShutdownWithoutLeavingRunningTasks() {
val server = MockWebServer()
val client = OkHttpClient()
val serverSocket = AtomicReference<WebSocket>()
val opened = CountDownLatch(1)
val closing = CountDownLatch(1)
val closed = CountDownLatch(1)
server.enqueue(
MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) {
serverSocket.set(webSocket)
}
override fun onClosing(
webSocket: WebSocket,
code: Int,
reason: String,
) {
closing.countDown()
}
},
),
)
server.start()
val socket =
client.newWebSocket(
Request.Builder().url(server.url("/")).build(),
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) {
opened.countDown()
}
override fun onClosed(
webSocket: WebSocket,
code: Int,
reason: String,
) {
closed.countDown()
}
},
)
try {
assertTrue("The WebSocket must open", opened.await(5, TimeUnit.SECONDS))
assertTrue(socket.close(1000, "test complete"))
assertTrue("The mock peer must observe the close", closing.await(5, TimeUnit.SECONDS))
// Complete the handshake before shutdown stops the server's task runner.
assertTrue("The close handshake must complete", closed.await(5, TimeUnit.SECONDS))
// No fixed sleep or swallowed shutdown exception: an active server queue fails this regression.
server.shutdown()
} finally {
socket.cancel()
// MockWebServer's accepted socket has no outbound Call, so cancel() is not supported.
serverSocket.get()?.close(1000, "cleanup")
try {
server.shutdown()
} finally {
client.dispatcher.executorService.shutdown()
client.connectionPool.evictAll()
}
}
}
}
@@ -118,6 +118,48 @@ class SessionPromptTypesTest {
assertEquals(setOf("session_id", "omit_messages", "profile"), (encoded as JsonObject).keys)
}
@Test
fun resumeWithDesktopSourceEncodesSourceKey() {
val params =
SessionResumeParams(
sessionId = "s-457",
source = DESKTOP_SESSION_SOURCE,
omitMessages = true,
)
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionResumeParams.serializer(), params)
val expected =
buildJsonObject {
put("session_id", "s-457")
put("source", "desktop")
put("omit_messages", true)
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("session_id", "source", "omit_messages"), (encoded as JsonObject).keys)
}
@Test
fun resumeOmitsSourceWhenUnset() {
val encoded =
OkHttpProvider.json.encodeToJsonElement(
SessionResumeParams.serializer(),
SessionResumeParams(sessionId = "s-458"),
)
assertTrue(encoded is JsonObject)
assertNull((encoded as JsonObject)["source"])
}
@Test
fun createAndResumeDeclareTheSameDesktopSource() {
// #1450: create passed source and resume did not, so the gateway resolved the resumed
// runtime from its host env ("tui") and staged a bogus surface switch. Same constant,
// so the two call sites cannot drift again.
assertEquals("desktop", DESKTOP_SESSION_SOURCE)
}
@Test
fun resumeWithExplicitFalseOmitMessagesIsEncoded() {
val params = SessionResumeParams(sessionId = "s-789", omitMessages = false)
@@ -330,7 +330,13 @@ class NotificationReplyReceiverTest {
verify {
HermesWsClient.request(
WsMethods.SESSION_RESUME,
match { it.str("session_id") == "session-abc" && it["omit_messages"] == JsonPrimitive(true) },
match {
it.str("session_id") == "session-abc" &&
it["omit_messages"] == JsonPrimitive(true) &&
// #1450: source must match session.create, or the gateway resolves the
// resumed runtime from its host env and stages a bogus surface switch.
it["source"] == JsonPrimitive("desktop")
},
any(),
)
HermesWsClient.request(
@@ -0,0 +1,464 @@
package com.m57.hermescontrol.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
/**
* Regression coverage for issue #1451:
* Transcript ordering when REST confirms a newer prompt while older live rows
* (observed live user/tool + review notices) remain unmatched/unconfirmed.
*/
class ChatLiveOrderingRegressionTest {
@Test
fun unconfirmedLiveUserAndToolWithReviewNoticesStayBeforeConfirmedNewerPrompt() {
// Given an observed live history:
// 1. Live user prompt (unconfirmed by REST)
// 2. Live tool call / result (unconfirmed by REST)
// 3. First system review notice
// 4. Second system review notice
// 5. Later live user prompt
val liveUserOld =
ChatMessage(
id = "live-user-old",
role = MessageRole.USER,
content = "first task",
timestamp = 1_000L,
)
val liveTool =
ChatMessage(
id = "live-tool-1",
role = MessageRole.TOOL,
content = """{"result":{"output":"task done","exit_code":0}}""",
toolName = "terminal",
toolCallId = "call_task_1",
timestamp = 1_100L,
)
val systemReview1 =
ChatMessage(
id = "sys-review-1",
role = MessageRole.SYSTEM,
content = "💾 Self-improvement review: skill updated",
timestamp = 1_200L,
)
val systemReview2 =
ChatMessage(
id = "sys-review-2",
role = MessageRole.SYSTEM,
content = "💾 Self-improvement review: playbook refreshed",
timestamp = 1_300L,
)
val liveUserNew =
ChatMessage(
id = "live-user-new",
role = MessageRole.USER,
content = "second prompt",
timestamp = 1_400L,
)
val currentMessages =
listOf(
liveUserOld,
liveTool,
systemReview1,
systemReview2,
liveUserNew,
)
// REST sync only returns/confirms the second prompt (e.g. rest-s-5),
// while the older live user/tool and review notices remain unmatched in REST.
val restConfirmedNew =
ChatMessage(
id = "rest-s-5",
role = MessageRole.USER,
content = "second prompt",
timestamp = 1_450L,
)
val restMessages = listOf(restConfirmedNew)
// When merged with chronological = true, preserveLiveIds = false
val merged =
mergeTranscriptWithLive(
restMessages = restMessages,
currentMessages = currentMessages,
chronological = true,
preserveLiveIds = false,
)
// Then:
// 1. All rows must be preserved.
assertEquals(5, merged.size)
// 2. Ordering contract: the older live user, tool, and two system review notices
// MUST remain before the newer confirmed prompt.
val expectedIds =
listOf(
"live-user-old",
"live-tool-1",
"sys-review-1",
"sys-review-2",
"live-user-new", // Merged with rest-s-5; when role=USER, match's ID is preserved, restId recorded
)
assertEquals(expectedIds, merged.map { it.id })
// 3. The confirmed prompt acquires its canonical REST identity
val confirmedRow = merged.last()
assertEquals("rest-s-5", confirmedRow.canonicalRestId)
assertEquals(MessageRole.USER, confirmedRow.role)
assertEquals("second prompt", confirmedRow.content)
// 4. Older unconfirmed rows must NOT invent or acquire rest identities
val unconfirmedRows = merged.take(4)
unconfirmedRows.forEach { row ->
assertNotEquals("rest-s-5", row.restId)
assertEquals(null, row.canonicalRestId)
}
// 5. Repeated merge must be idempotent
val repeatedMerge =
mergeTranscriptWithLive(
restMessages = restMessages,
currentMessages = merged,
chronological = true,
preserveLiveIds = false,
)
assertEquals(merged.map { it.id }, repeatedMerge.map { it.id })
assertEquals(merged, repeatedMerge)
}
@Test
fun controlPlainAppendAndNonConfirmationMaintainsObservedLiveOrder() {
// Control scenario:
// Current live transcript contains an old user prompt, tool call, two review notices,
// and a later user prompt.
// REST returns no confirmations (emptyList) or an unmatching server message.
val liveUserOld =
ChatMessage(
id = "live-user-old",
role = MessageRole.USER,
content = "first task",
timestamp = 1_000L,
)
val liveTool =
ChatMessage(
id = "live-tool-1",
role = MessageRole.TOOL,
content = """{"result":{"output":"task done","exit_code":0}}""",
toolName = "terminal",
toolCallId = "call_task_1",
timestamp = 1_100L,
)
val systemReview1 =
ChatMessage(
id = "sys-review-1",
role = MessageRole.SYSTEM,
content = "💾 Self-improvement review: note 1",
timestamp = 1_200L,
)
val systemReview2 =
ChatMessage(
id = "sys-review-2",
role = MessageRole.SYSTEM,
content = "💾 Self-improvement review: note 2",
timestamp = 1_300L,
)
val liveUserNew =
ChatMessage(
id = "live-user-new",
role = MessageRole.USER,
content = "second prompt",
timestamp = 1_400L,
)
val currentMessages =
listOf(
liveUserOld,
liveTool,
systemReview1,
systemReview2,
liveUserNew,
)
// Case A: REST sync is empty
val mergedEmpty =
mergeTranscriptWithLive(
restMessages = emptyList(),
currentMessages = currentMessages,
chronological = true,
preserveLiveIds = false,
)
assertEquals(
listOf(
"live-user-old",
"live-tool-1",
"sys-review-1",
"sys-review-2",
"live-user-new",
),
mergedEmpty.map {
it.id
},
)
// Case B: REST returns an unrelated / earlier server message (e.g. session start rest-s-0)
val canonicalPrior =
ChatMessage(
id = "rest-s-0",
role = MessageRole.USER,
content = "very first conversation starter",
timestamp = 500L,
)
val mergedWithPrior =
mergeTranscriptWithLive(
restMessages = listOf(canonicalPrior),
currentMessages = currentMessages,
chronological = true,
preserveLiveIds = false,
)
// Canonical server rows maintain server order, while observed live unconfirmed block stays intact
assertEquals(
listOf("rest-s-0", "live-user-old", "live-tool-1", "sys-review-1", "sys-review-2", "live-user-new"),
mergedWithPrior.map { it.id },
)
// Idempotence check on control
val idempotentMergedWithPrior =
mergeTranscriptWithLive(
restMessages = listOf(canonicalPrior),
currentMessages = mergedWithPrior,
chronological = true,
preserveLiveIds = false,
)
assertEquals(mergedWithPrior.map { it.id }, idempotentMergedWithPrior.map { it.id })
}
@Test
fun sessionStartMarkerRemainsBeforeOldUnresolvedUserToolReviewBlockWhenNewerUserConfirmsAsRestS0() {
val sessionStart =
ChatMessage(
id = "system-session-start",
role = MessageRole.SYSTEM,
content = "Session created",
timestamp = 100L,
)
val liveUserOld =
ChatMessage(
id = "live-user-old",
role = MessageRole.USER,
content = "unconfirmed first prompt",
timestamp = 200L,
)
val liveTool =
ChatMessage(
id = "live-tool-1",
role = MessageRole.TOOL,
content = """{"result":"done"}""",
toolName = "terminal",
toolCallId = "call_1",
timestamp = 300L,
)
val sysReview =
ChatMessage(
id = "sys-review-1",
role = MessageRole.SYSTEM,
content = "💾 Self-improvement review: note",
timestamp = 400L,
)
val liveUserNew =
ChatMessage(
id = "live-user-new",
role = MessageRole.USER,
content = "confirmed second prompt",
timestamp = 500L,
)
val currentMessages =
listOf(
sessionStart,
liveUserOld,
liveTool,
sysReview,
liveUserNew,
)
// Newer observed USER confirms as canonical index 0 (rest-s-0)
val restConfirmedNew =
ChatMessage(
id = "rest-s-0",
role = MessageRole.USER,
content = "confirmed second prompt",
timestamp = 550L,
)
val merged =
mergeTranscriptWithLive(
restMessages = listOf(restConfirmedNew),
currentMessages = currentMessages,
chronological = true,
preserveLiveIds = false,
)
val expectedIds =
listOf(
"system-session-start",
"live-user-old",
"live-tool-1",
"sys-review-1",
"live-user-new",
)
assertEquals(expectedIds, merged.map { it.id })
assertEquals("rest-s-0", merged.last().canonicalRestId)
}
@Test
fun canonicalRowsRemainServerOrderedAcrossGapsWithOldLiveBlockBeforeNextConfirmedUserAcrossSkewedTimestamps() {
// Skewed/inverted timestamps across rows
val canonicalPrior =
ChatMessage(
id = "rest-s-2",
role = MessageRole.USER,
content = "prior prompt",
timestamp = 8_000L, // skewed higher timestamp than later turns
)
val liveUserOld =
ChatMessage(
id = "live-user-old",
role = MessageRole.USER,
content = "unresolved old prompt",
timestamp = 2_000L,
)
val liveTool =
ChatMessage(
id = "live-tool-1",
role = MessageRole.TOOL,
content = """{"result":"ok"}""",
toolName = "terminal",
toolCallId = "call_skew",
timestamp = 2_000L, // equal timestamp
)
val sysReview =
ChatMessage(
id = "sys-review-1",
role = MessageRole.SYSTEM,
content = "💾 Self-improvement review: updated",
timestamp = 1_000L, // earlier timestamp
)
val liveUserNew =
ChatMessage(
id = "live-user-new",
role = MessageRole.USER,
content = "next user prompt",
timestamp = 3_000L,
)
val currentMessages =
listOf(
canonicalPrior,
liveUserOld,
liveTool,
sysReview,
liveUserNew,
)
// Gap in canonical sequence: rest-s-2 then rest-s-9 (gap 3..8 missing/unloaded)
val canonicalFollow =
ChatMessage(
id = "rest-s-9",
role = MessageRole.USER,
content = "next user prompt",
timestamp = 2_500L, // skewed timestamp compared to rest-s-2
)
val restMessages = listOf(canonicalPrior, canonicalFollow)
val merged =
mergeTranscriptWithLive(
restMessages = restMessages,
currentMessages = currentMessages,
chronological = true,
preserveLiveIds = false,
)
val expectedIds =
listOf(
"rest-s-2",
"live-user-old",
"live-tool-1",
"sys-review-1",
"live-user-new",
)
assertEquals(expectedIds, merged.map { it.id })
// Repeated merge must remain identical
val repeatedMerge =
mergeTranscriptWithLive(
restMessages = restMessages,
currentMessages = merged,
chronological = true,
preserveLiveIds = false,
)
assertEquals(merged.map { it.id }, repeatedMerge.map { it.id })
assertEquals(merged, repeatedMerge)
}
@Test
fun assistantConfirmationDoesNotPullUnresolvedUserOutOfPendingTail() {
val unresolvedUser =
ChatMessage(
id = "live-user-unresolved",
role = MessageRole.USER,
content = "prompt not matching rest",
timestamp = 1_000L,
)
val liveTool =
ChatMessage(
id = "live-tool-1",
role = MessageRole.TOOL,
content = """{"result":"in progress"}""",
toolName = "terminal",
toolCallId = "call_assistant_test",
timestamp = 1_100L,
)
val liveAssistant =
ChatMessage(
id = "live-assistant-1",
role = MessageRole.ASSISTANT,
content = "Here is the response",
timestamp = 1_200L,
)
val currentMessages =
listOf(
unresolvedUser,
liveTool,
liveAssistant,
)
// REST confirms only the ASSISTANT message as rest-s-4 (e.g. user was dropped or mismatched)
val restConfirmedAssistant =
ChatMessage(
id = "rest-s-4",
role = MessageRole.ASSISTANT,
content = "Here is the response",
timestamp = 1_200L,
)
val merged =
mergeTranscriptWithLive(
restMessages = listOf(restConfirmedAssistant),
currentMessages = currentMessages,
chronological = true,
preserveLiveIds = false,
)
// Assistant confirmation alone must not anchor/pull the unresolved user or tool
// before rest-s-4. The confirmed assistant appears first, unresolved user/tool remain in pending tail.
val expectedIds =
listOf(
"rest-s-4",
"live-user-unresolved",
"live-tool-1",
)
assertEquals(expectedIds, merged.map { it.id })
}
}
@@ -0,0 +1,253 @@
package com.m57.hermescontrol.ui.chat
import kotlinx.coroutines.CancellationException
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.ByteArrayInputStream
import java.io.IOException
import java.io.InputStream
import java.util.UUID
import java.util.concurrent.Callable
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
class ChatPastedImageStoreTest {
@get:Rule val temp = TemporaryFolder()
@Test
fun startupSweepDeletesOnlyPastedImagesOlderThanSevenDays() {
val cacheDir = temp.newFolder("cache")
val directory = cacheDir.resolve("pasted_images").apply { mkdir() }
val now = 2_000_000_000_000L
val cutoff = now - TimeUnit.DAYS.toMillis(7)
val stale = stagePastedImage(ByteArrayInputStream(byteArrayOf(1)), directory, "png")!!
assertTrue(stale.setLastModified(cutoff - 1000))
val boundary = directory.resolve("boundary.png").apply { writeText("keep") }
assertTrue(boundary.setLastModified(cutoff))
val recent = directory.resolve("recent.png").apply { writeText("keep") }
assertTrue(recent.setLastModified(now))
val future = directory.resolve("future.png").apply { writeText("keep") }
assertTrue(future.setLastModified(now + 1000))
val unrelated = cacheDir.resolve("unrelated.png").apply { writeText("keep") }
assertTrue(unrelated.setLastModified(cutoff - 1000))
val nested =
directory
.resolve("nested")
.apply { mkdir() }
.resolve("old.png")
.apply { writeText("keep") }
assertTrue(nested.setLastModified(cutoff - 1000))
cleanStalePastedImages(cacheDir, now)
cleanStalePastedImages(cacheDir, now)
assertFalse(stale.exists())
listOf(boundary, recent, future, unrelated, nested).forEach { assertEquals("keep", it.readText()) }
}
@Test
fun startupSweepDoesNotCreateAMissingCacheDirectory() {
val cacheDir = temp.newFolder("empty-cache")
cleanStalePastedImages(cacheDir)
assertTrue(cacheDir.listFiles()!!.isEmpty())
}
@Test
fun startupSweepPreservesAnImportCreatedAfterTheStartupSnapshot() {
val cacheDir = temp.newFolder("live-cache")
val directory = cacheDir.resolve("pasted_images")
val startupTime = System.currentTimeMillis()
val imported = stagePastedImage(ByteArrayInputStream(byteArrayOf(1, 2)), directory, "png")!!
cleanStalePastedImages(cacheDir, startupTime)
assertArrayEquals(byteArrayOf(1, 2), imported.readBytes())
}
@Test
fun exactLimitIsAcceptedWithItsActualByteCount() {
val dir = temp.newFolder("exact")
val bytes = byteArrayOf(1, 2, 3, 4)
val file = stagePastedImage(ByteArrayInputStream(bytes), dir, "png", maxBytes = 4)
assertArrayEquals(bytes, file!!.readBytes())
assertEquals(4L, file.length())
assertTrue(file.name.endsWith(".png"))
assertEquals(listOf(file), dir.listFiles()!!.toList())
}
@Test
fun callerLimitAbovePerFileCapStillRejectsAfterTenMiBAndCleansUp() {
val dir = temp.newFolder("per-file-cap")
val input = ByteArrayInputStream(ByteArray(10 * 1024 * 1024 + 1))
try {
stagePastedImage(input, dir, "png", maxBytes = 20L * 1024 * 1024)
fail("Expected the per-file limit")
} catch (_: PastedImageTooLargeException) {
assertTrue(dir.listFiles()!!.isEmpty())
}
}
@Test
fun unknownSizeIsBoundedByTheActualStreamAndReadsAtMostLimitPlusOne() {
val dir = temp.newFolder("oversize")
val input =
object : ByteArrayInputStream(ByteArray(100)) {
var consumed = 0
override fun read(
b: ByteArray,
off: Int,
len: Int,
): Int = super.read(b, off, len).also { if (it > 0) consumed += it }
}
try {
stagePastedImage(input, dir, "png", maxBytes = 4)
fail("Expected size limit rejection")
} catch (_: PastedImageTooLargeException) {
// The exact limit is accepted by the preceding test; only the extra byte rejects.
}
assertEquals(5, input.consumed)
assertTrue(dir.listFiles()!!.isEmpty())
}
@Test
fun emptyStreamIsRejectedAndLeavesNoCacheFile() {
val dir = temp.newFolder("empty")
assertNull(stagePastedImage(ByteArrayInputStream(byteArrayOf()), dir, "webp", maxBytes = 4))
assertTrue(dir.listFiles()!!.isEmpty())
}
@Test
fun streamFailureDeletesPartialFileAndClosesSource() {
val dir = temp.newFolder("broken")
val input =
object : InputStream() {
var reads = 0
var closed = false
override fun read(): Int = if (reads++ == 0) 7 else throw IOException("provider failed")
override fun read(
b: ByteArray,
off: Int,
len: Int,
): Int =
if (reads++ == 0) {
b[off] = 7
1
} else {
throw IOException("provider failed")
}
override fun close() {
closed = true
}
}
try {
stagePastedImage(input, dir, "png", maxBytes = 4)
fail("Expected stream failure")
} catch (_: IOException) {
assertTrue(input.closed)
assertTrue(dir.listFiles()!!.isEmpty())
}
}
@Test
fun existingNameCollisionNeverTruncatesOrDeletesExistingFile() {
val dir = temp.newFolder("collision")
val id = UUID.randomUUID().toString()
val sentinel = dir.resolve("$id.png")
sentinel.writeText("keep me")
try {
stagePastedImage(ByteArrayInputStream(byteArrayOf(1)), dir, "png", newId = { id })
fail("Expected exclusive-create failure")
} catch (_: IOException) {
assertEquals("keep me", sentinel.readText())
assertEquals(listOf(sentinel), dir.listFiles()!!.toList())
}
}
@Test
fun simultaneousImportsUseDistinctFilesAndKeepTheirOwnBytes() {
val dir = temp.newFolder("concurrent")
val executor = Executors.newFixedThreadPool(8)
try {
val tasks =
(1..16).map { n ->
Callable {
val bytes = ByteArray(n) { n.toByte() }
val file = stagePastedImage(ByteArrayInputStream(bytes), dir, "jpeg", maxBytes = 64)!!
file to bytes
}
}
val results = executor.invokeAll(tasks).map { it.get(10, TimeUnit.SECONDS) }
assertEquals(16, results.map { it.first.name }.toSet().size)
results.forEach { (file, bytes) -> assertArrayEquals(bytes, file.readBytes()) }
assertEquals(16, dir.listFiles()!!.size)
} finally {
executor.shutdownNow()
}
}
@Test
fun cancellationCheckDeletesPartialFileAndPropagatesCancellation() {
val dir = temp.newFolder("cancel")
var checks = 0
val input = ByteArrayInputStream(ByteArray(100))
try {
stagePastedImage(
input,
dir,
"png",
maxBytes = 100,
checkActive = {
if (++checks == 2) throw CancellationException("cancelled")
},
)
fail("Expected cancellation")
} catch (_: CancellationException) {
assertTrue(dir.listFiles()!!.isEmpty())
assertTrue(checks >= 2)
}
}
@Test
fun aDiscardedResultDeletesOnlyItsOwnedFile() {
val dir = temp.newFolder("discard")
val file = stagePastedImage(ByteArrayInputStream(byteArrayOf(1)), dir, "png")!!
val unrelated = dir.resolve("unrelated.png").apply { writeText("keep") }
val result =
PastedImage(
attachment =
com.m57.hermescontrol.data.model.Attachment(
uri = "content://example/file",
name = file.name,
mimeType = "image/png",
size = 1,
),
file = file,
)
result.discard()
assertFalse(file.exists())
assertTrue(unrelated.exists())
assertEquals("keep", unrelated.readText())
}
}
@@ -49,6 +49,29 @@ class ChatSendStoreTest {
assertEquals(setOf("send-1"), confirmed)
}
@Test
fun rowIdOnHistoryPageConfirmsAcceptedReceiptWithoutMergeMatch() {
val receipts =
listOf(
pending("send-1", "A").copy(userRowId = 334755L),
pending("send-2", "B").copy(userRowId = 999L),
pending("send-3", "C"),
)
val confirmed = pendingSendIdsConfirmedByRowIds(setOf(334755L), receipts)
assertEquals(setOf("send-1"), confirmed)
}
@Test
fun rowIdReceiptIsNotDowngradedButIdlessReceiptIs() {
val withRow = pending("send-1", "A").copy(userRowId = 334755L)
val idless = pending("send-2", "B")
assertTrue(!canDemoteAcceptedReceipt(withRow))
assertTrue(canDemoteAcceptedReceipt(idless))
}
@Test
fun durableAliasConsumesOnlyOneDuplicateReceiptOccurrence() {
val existing =
@@ -360,6 +360,14 @@ class ChatViewModelTest {
arg<((String) -> Unit)?>(2)?.invoke(id)
id
}
// Typed redirects bypass send(); keep this fixture off the real singleton transport.
every { HermesWsClient.sendRedirect(any(), any(), any()) } answers {
reqCount++
val id = "req-redirect-$reqCount"
sentRequestMethods += WsMethods.SESSION_REDIRECT to id
arg<((String) -> Unit)?>(2)?.invoke(id)
id
}
every { HermesWsClient.respondToServerRequest(any(), any()) } returns true
every { HermesWsClient.respondToServerRequestError(any(), any(), any()) } returns true
@@ -396,6 +404,73 @@ class ChatViewModelTest {
unmockkAll()
}
@Test
fun pastedImages_areAddedOnlyToCapturedReadySessionWithoutSubmitting() =
runTest(testDispatcher) {
val (viewModel, _) = createViewModelWithSession()
val target = checkNotNull(viewModel.captureAttachmentTarget())
val image = Attachment("content://test/pasted.png", "pasted.png", "image/png", 4)
val messagesBefore = viewModel.uiState.value.messages
assertTrue(viewModel.addPastedAttachments(target, listOf(image)))
runCurrent()
assertEquals(listOf(image), viewModel.uiState.value.pendingAttachments)
assertEquals(messagesBefore, viewModel.uiState.value.messages)
verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) }
}
@Test
fun pastedImages_rejectLateCompletionAfterSwitchAwayAndBack() =
runTest(testDispatcher) {
val (viewModel, sessionId) = createViewModelWithSession()
val target = checkNotNull(viewModel.captureAttachmentTarget())
viewModel.switchSession("other-paste-test-session")
viewModel.switchSession(sessionId)
val image = Attachment("content://test/pasted.png", "pasted.png", "image/png", 4)
assertFalse(viewModel.addPastedAttachments(target, listOf(image)))
assertTrue(
viewModel.uiState.value.pendingAttachments
.isEmpty(),
)
}
@Test
fun pastedImages_rejectChangedServerAndProfilesAndGeneration() =
runTest(testDispatcher) {
val (viewModel, _) = createViewModelWithSession()
val target = checkNotNull(viewModel.captureAttachmentTarget())
val image = Attachment("content://test/pasted.png", "pasted.png", "image/png", 4)
for (stale in listOf(
target.copy(baseUrl = "http://other-paste-test.local/"),
target.copy(connectionProfileId = "other-connection"),
target.copy(agentProfileId = "other-agent"),
target.copy(generation = target.generation + 1),
)) {
assertFalse(viewModel.addPastedAttachments(stale, listOf(image)))
}
assertTrue(
viewModel.uiState.value.pendingAttachments
.isEmpty(),
)
}
@Test
fun pastedImages_cannotCaptureTargetBeforeReady() =
runTest(testDispatcher) {
val viewModel = createViewModel()
advanceUntilIdle()
assertNull(viewModel.captureAttachmentTarget())
}
@Test
fun pastedImages_rejectDisconnectedSessionBeforeCombinedUiStateCatchesUp() =
runTest(testDispatcher) {
val (viewModel, _) = createViewModelWithSession()
val target = checkNotNull(viewModel.captureAttachmentTarget())
mockConnectionStatus.value = ConnectionStatus.DISCONNECTED
assertNull(viewModel.captureAttachmentTarget())
assertFalse(viewModel.addPastedAttachments(target, emptyList()))
}
// ── Helpers ──────────────────────────────────────────────────────────────
/** Create a ViewModel with the fake repo injected directly. */
@@ -498,6 +573,32 @@ class ChatViewModelTest {
assertEquals("op-1218", operation?.opId)
}
@Test
fun sessionResume_sendsDesktopSourceLikeSessionCreate() =
runTest {
// #1450: session.create declared source="desktop" but session.resume omitted it, so
// the gateway resolved the resumed runtime from its host env ("tui") and staged a
// bogus surface switch that also unloaded the desktop_ui toolset.
val viewModel = createViewModel()
advanceUntilIdle()
val captured = mutableListOf<Pair<String, Map<String, Any>>>()
every { HermesWsClient.send(any(), any(), any()) } answers {
val id = "req-${captured.size + 1}"
captured.add(arg<String>(0) to (arg<Map<String, Any>>(1)))
arg<((String) -> Unit)?>(2)?.invoke(id)
id
}
viewModel.switchSession("stored-session")
advanceUntilIdle()
val resumeSent = captured.firstOrNull { it.first == WsMethods.SESSION_RESUME }
assertNotNull("session.resume should be dispatched", resumeSent)
assertEquals(JsonPrimitive("stored-session"), resumeSent!!.second["session_id"])
assertEquals(JsonPrimitive("desktop"), resumeSent.second["source"])
}
@Test
fun sessionResume_restoresRetainedFailureAsNonDurablePartial() =
runTest {
@@ -627,7 +728,7 @@ class ChatViewModelTest {
val (viewModel, sessionId) = createViewModelWithSession()
every {
HermesWsClient.request(WsMethods.COMMAND_DISPATCH, any(), any())
HermesWsClient.request(WsMethods.SLASH_EXEC, any(), any(), true)
} returns
CompletableDeferred(
mapOf("type" to "exec", "output" to "**Available Commands:**\n\u2022 `/status`\n\u2022 `/new`"),
@@ -1287,7 +1388,7 @@ class ChatViewModelTest {
viewModel.sendMessage("/status")
advanceUntilIdle()
verify { HermesWsClient.send(WsMethods.COMMAND_DISPATCH, any(), any()) }
verify { HermesWsClient.request(WsMethods.SLASH_EXEC, any(), any(), true) }
}
@Test
@@ -1298,7 +1399,7 @@ class ChatViewModelTest {
viewModel.sendMessage("/sessions")
advanceUntilIdle()
verify { HermesWsClient.send(WsMethods.COMMAND_DISPATCH, any(), any()) }
verify { HermesWsClient.request(WsMethods.SLASH_EXEC, any(), any(), true) }
}
@Test
@@ -1309,7 +1410,7 @@ class ChatViewModelTest {
viewModel.sendMessage("/stats")
advanceUntilIdle()
verify { HermesWsClient.send(WsMethods.COMMAND_DISPATCH, any(), any()) }
verify { HermesWsClient.request(WsMethods.SLASH_EXEC, any(), any(), true) }
}
@Test
@@ -1635,6 +1736,7 @@ class ChatViewModelTest {
JsonObject(
mapOf(
"session_id" to JsonPrimitive("session-from-notification"),
"source" to JsonPrimitive("desktop"),
"omit_messages" to JsonPrimitive(true),
),
),
@@ -5088,6 +5190,7 @@ class ChatViewModelTest {
JsonObject(
mapOf(
"session_id" to JsonPrimitive("session-456"),
"source" to JsonPrimitive("desktop"),
"omit_messages" to JsonPrimitive(true),
),
),
@@ -5943,6 +6046,7 @@ class ChatViewModelTest {
JsonObject(
mapOf(
"session_id" to JsonPrimitive("session-456"),
"source" to JsonPrimitive("desktop"),
"omit_messages" to JsonPrimitive(true),
),
),
@@ -10636,7 +10740,8 @@ class ChatViewModelTest {
.last()
.content,
)
assertEquals(PendingSendState.UNKNOWN, store.all().single().state)
// #1427: a mismatched history row cannot retire the gateway-confirmed receipt.
assertEquals(PendingSendState.ACCEPTED, store.all().single().state)
assertTrue(vm.uiState.value.isSessionReady)
assertTrue(vm.sendMessage("probe-second"))
advanceUntilIdle()
@@ -10648,9 +10753,10 @@ class ChatViewModelTest {
assertNull(vm.uiState.value.errorMessage)
verify(exactly = 1) { HermesWsClient.sendMessage(session, "probe-second", any(), any()) }
verify(exactly = 1) { HermesWsClient.sendMessage(session, "probe-first", any(), any()) }
assertEquals(PendingSendState.UNKNOWN, store.all().single { it.text == "probe-first" }.state)
assertEquals(PendingSendState.ACCEPTED, store.all().single { it.text == "probe-first" }.state)
// The accepted row 10 remains visible separately from the unrelated history row 20.
assertEquals(
1,
2,
vm.transcriptState.value.messages
.count { it.content == "probe-first" },
)
@@ -478,4 +478,27 @@ class FullBleedTurnsTest {
assertEquals(setOf("u1", "a2"), matchedMessageIds(msgs, listOf(0, 2, 0)))
assertEquals(emptySet<String>(), matchedMessageIds(msgs, listOf(9)))
}
@Test
fun `react_to_message tool rows are hidden but other tools stay`() {
val messages =
listOf(
msg("u1", MessageRole.USER),
msg("react", MessageRole.TOOL).copy(toolName = "react_to_message"),
msg("term", MessageRole.TOOL).copy(toolName = "terminal"),
msg("a1", MessageRole.ASSISTANT),
)
val agent = groupIntoTurns(messages).filterIsInstance<ChatTurn.Agent>().single()
assertEquals(
listOf("term", "a1"),
agent.entries.map {
when (it) {
is AgentEntry.Prose -> it.message.id
is AgentEntry.ToolRow -> it.message.id
is AgentEntry.SystemEvent -> it.message.id
}
},
)
assertEquals(emptyList<SearchMatch>(), ChatSearchController().findMatches(messages.take(2), "react").matches)
}
}
@@ -0,0 +1,233 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.local.toEntity
import com.m57.hermescontrol.data.local.toUiModel
import com.m57.hermescontrol.data.model.SessionMessage
import com.m57.hermescontrol.data.ws.WsEvent
import kotlinx.serialization.json.JsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** #1433: gateway notices are per-session transcript rows that keep their position. */
class GatewayNoticeTest {
private val compactedText = "✓ Context compaction complete — continuing turn..."
private fun status(
kind: String,
text: String?,
sessionId: String? = "s1",
) = WsEvent.StatusUpdate(
status = null,
data = mapOf("kind" to kind, "text" to text),
sessionId = sessionId,
)
private fun reduce(
state: ChatUiState,
event: WsEvent,
streaming: StreamingState = StreamingState(),
) = ChatWsEventReducer.reduce(state, streaming, event, currentSessionId = "s1")
@Test
fun sentinelMatchesEveryGatewayInterruptShape() {
listOf(
"[the reply degenerated into a repetition loop and was interrupted]",
"Operation interrupted.",
"Operation interrupted: waiting for the provider to recover (cycle 1/3).",
"Operation interrupted: retrying API call after error (retry 2/5).",
"Operation interrupted: handling API error (RateLimitError: slow down).",
"Operation interrupted: retrying empty response from model (retry 1/2).",
"Operation interrupted during retry (timeout, attempt 1/3).",
" Operation interrupted.\n",
).forEach { assertTrue(it, GatewayNotice.isInterruptSentinel(it)) }
listOf(
"Operation interrupted",
"Operation interrupted. Here is what I found so far",
"The operation was interrupted.",
"Operation interrupted: first line.\nsecond line.",
).forEach { assertFalse(it, GatewayNotice.isInterruptSentinel(it)) }
}
@Test
fun compactedNoticeBecomesPersistedSystemRowAndClearsTailState() {
val user = ChatMessage(id = "u", role = MessageRole.USER, content = "go")
val state =
ChatUiState(
currentSessionId = "s1",
messages = listOf(user),
isCompressing = true,
compressionStatus = "🗜️ Compacting context",
)
val result = reduce(state, status("compacted", compactedText))
assertFalse(result.state.isCompressing)
assertNull(result.state.compressionStatus)
val notice = result.state.messages.last()
assertEquals(MessageRole.SYSTEM, notice.role)
assertEquals(compactedText, notice.content)
assertTrue(result.effects.any { it is ReducerEffect.PersistMessage && it.message.id == notice.id })
}
@Test
fun replayedNoticeInSameTurnDoesNotStack() {
val state =
ChatUiState(
currentSessionId = "s1",
messages = listOf(ChatMessage(role = MessageRole.USER, content = "go")),
)
val once = reduce(state, status("compacted", compactedText)).state
val twice = reduce(once, status("compacted", compactedText)).state
assertEquals(1, twice.messages.count { it.role == MessageRole.SYSTEM })
val nextTurn = twice.copy(messages = twice.messages + ChatMessage(role = MessageRole.USER, content = "again"))
val again = reduce(nextTurn, status("compacted", compactedText)).state
assertEquals(2, again.messages.count { it.role == MessageRole.SYSTEM })
}
@Test
fun otherSessionsStatusUpdatesAreIgnored() {
val state = ChatUiState(currentSessionId = "s1")
listOf("compacting", "compacted", "goal", "warn").forEach { kind ->
val result = reduce(state, status(kind, "text for $kind", sessionId = "other"))
assertEquals(kind, state, result.state)
}
}
@Test
fun durableKindsAppendAndTransientKindsDoNot() {
val state = ChatUiState(currentSessionId = "s1")
listOf("goal", "loop", "warn", "fallback").forEach { kind ->
assertEquals(kind, 1, reduce(state, status(kind, "✓ $kind")).state.messages.size)
}
listOf("status", "lifecycle", "process", "heartbeat", "ready").forEach { kind ->
assertTrue(kind, reduce(state, status(kind, "x")).state.messages.isEmpty())
}
}
@Test
fun repetitionLoopCompletionDropsLoopedPartialAndAppendsNotice() {
val sealed = ChatMessage(id = "looped", role = MessageRole.ASSISTANT, content = "again again again")
val state = ChatUiState(currentSessionId = "s1", messages = listOf(sealed), isAgentTyping = true)
val event =
WsEvent.MessageComplete(
text = GatewayNotice.REPETITION_LOOP_INTERRUPTED,
sessionId = "s1",
rawPayload = mapOf("status" to "interrupted"),
)
val result = reduce(state, event, StreamingState(interruptedMessage = sealed))
assertEquals(listOf(MessageRole.SYSTEM), result.state.messages.map { it.role })
assertEquals(
GatewayNotice.REPETITION_LOOP_INTERRUPTED,
result.state.messages
.single()
.content,
)
assertFalse(result.state.isAgentTyping)
assertTrue(result.effects.contains(ReducerEffect.DeleteLocalMessage("looped")))
}
@Test
fun operationInterruptedCompletionKeepsRealPartial() {
val stream = ChatMessage(id = "p", role = MessageRole.ASSISTANT, content = "half an answer", isStreaming = true)
val state = ChatUiState(currentSessionId = "s1")
val event = WsEvent.MessageComplete(text = "Operation interrupted.", sessionId = "s1")
val result = reduce(state, event, StreamingState(streamingMessage = stream))
assertEquals(
listOf("p", null),
result.state.messages.map {
if (it.role ==
MessageRole.SYSTEM
) {
null
} else {
it.id
}
},
)
assertFalse(
result.state.messages
.first()
.isStreaming,
)
assertEquals(
"Operation interrupted.",
result.state.messages
.last()
.content,
)
}
@Test
fun persistedSentinelRowMapsToSystemNoticeAndMergesWithLiveCopy() {
val rows =
listOf(
SessionMessage(id = 1, role = "user", content = JsonPrimitive("go")),
SessionMessage(id = 2, role = "assistant", content = JsonPrimitive("Operation interrupted.")),
SessionMessage(id = 3, role = "user", content = JsonPrimitive("next")),
SessionMessage(id = 4, role = "assistant", content = JsonPrimitive("done")),
)
val page = mapServerMessages("s", rows, 0, true, emptyList())
assertEquals(MessageRole.SYSTEM, page[1].role)
// Live view after the interrupted turn: the notice sits between the two turns.
val live =
listOf(
ChatMessage(id = "u1", role = MessageRole.USER, content = "go"),
ChatMessage(id = "n", role = MessageRole.SYSTEM, content = "Operation interrupted."),
ChatMessage(id = "u2", role = MessageRole.USER, content = "next"),
ChatMessage(id = "a2", role = MessageRole.ASSISTANT, content = "done"),
)
val merged = mergeTranscriptWithLive(page, live, preserveLiveIds = true)
assertEquals(
listOf("go", "Operation interrupted.", "next", "done"),
merged.map { it.content },
)
}
@Test
fun liveOnlyNoticeStaysInPlaceAcrossLaterTurnsAndRefresh() {
val page =
mapServerMessages(
"s",
listOf(
SessionMessage(id = 1, role = "user", content = JsonPrimitive("first")),
SessionMessage(id = 2, role = "assistant", content = JsonPrimitive("one")),
SessionMessage(id = 3, role = "user", content = JsonPrimitive("second")),
SessionMessage(id = 4, role = "assistant", content = JsonPrimitive("two")),
),
0,
true,
emptyList(),
)
val live =
listOf(
ChatMessage(id = "u1", role = MessageRole.USER, content = "first"),
ChatMessage(id = "a1", role = MessageRole.ASSISTANT, content = "one"),
ChatMessage(id = "c", role = MessageRole.SYSTEM, content = compactedText),
ChatMessage(id = "u2", role = MessageRole.USER, content = "second"),
ChatMessage(id = "a2", role = MessageRole.ASSISTANT, content = "two"),
)
val once = mergeTranscriptWithLive(page, live, preserveLiveIds = true)
val twice = mergeTranscriptWithLive(page, once, preserveLiveIds = true)
val expected = listOf("first", "one", compactedText, "second", "two")
assertEquals(expected, once.map { it.content })
assertEquals(expected, twice.map { it.content })
}
@Test
fun roomRoundTripNormalizesLegacyAssistantSentinel() {
val legacy = ChatMessage(id = "x", role = MessageRole.ASSISTANT, content = "Operation interrupted.")
val restored = legacy.toEntity("s").toUiModel()
assertEquals(MessageRole.SYSTEM, restored.role)
val prose = ChatMessage(id = "y", role = MessageRole.ASSISTANT, content = "fine")
assertEquals(MessageRole.ASSISTANT, prose.toEntity("s").toUiModel().role)
}
}
@@ -161,7 +161,7 @@ class SlashCommandDispatchRpcTest {
// can end up holding session.usage instead of command.dispatch (CI run
// 31137581335 on 4645a2b: slash-focus flake).
// Specific per-test request() stubs registered later take precedence.
every { HermesWsClient.request(any(), any(), any()) } answers {
every { HermesWsClient.request(any(), any(), any(), any()) } answers {
HermesWsClient.send(arg(0), arg(1)) {}
CompletableDeferred<Any?>(Unit)
}
@@ -194,7 +194,7 @@ class SlashCommandDispatchRpcTest {
}
@Test
fun `non-hardcoded slash command forwards name arg session_id to COMMAND_DISPATCH`() =
fun `non-hardcoded slash command forwards command and session_id to SLASH_EXEC`() =
runTest {
val (vm, sessionId) = createViewModelWithSession()
@@ -202,7 +202,7 @@ class SlashCommandDispatchRpcTest {
val paramsCalls = mutableListOf<Map<String, Any>>()
var captured = false
every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any(), any())
} answers {
captured = true
CompletableDeferred<Any?>(Unit)
@@ -216,14 +216,67 @@ class SlashCommandDispatchRpcTest {
// Captures hold ALL requests — the create flow's session.usage /
// context_breakdown can land after the dispatch one (capture race,
// CI 2026-08-06 + 2026-08-07). Find the record, don't trust "last".
val dispatchIndex = methodCalls.indexOf(WsMethods.COMMAND_DISPATCH)
assertTrue("expected COMMAND_DISPATCH, got $methodCalls", dispatchIndex >= 0)
val params = paramsCalls[dispatchIndex]
assertEquals(JsonPrimitive("help"), params["name"])
assertEquals(JsonPrimitive(""), params["arg"])
val execIndex = methodCalls.indexOf(WsMethods.SLASH_EXEC)
assertTrue("expected SLASH_EXEC, got $methodCalls", execIndex >= 0)
assertTrue(WsMethods.COMMAND_DISPATCH !in methodCalls)
val params = paramsCalls[execIndex]
assertEquals(JsonPrimitive("help"), params["command"])
assertEquals(JsonPrimitive(sessionId), params["session_id"])
}
@Test
fun `usage renders warning and suppresses intermediate error events`() =
runTest {
val (vm, _) = createViewModelWithSession()
every { HermesWsClient.request(WsMethods.SLASH_EXEC, any(), any(), true) } returns
CompletableDeferred<Any?>(mapOf("output" to "Session Token Usage", "warning" to "estimated"))
vm.sendMessage("/usage")
advanceUntilIdle()
verify(exactly = 1) { HermesWsClient.request(WsMethods.SLASH_EXEC, any(), any(), true) }
verify(exactly = 0) { HermesWsClient.request(WsMethods.COMMAND_DISPATCH, any(), any(), any()) }
assertEquals(
"warning: estimated\nSession Token Usage",
vm.uiState.value.messages
.last()
.content,
)
assertNull(vm.uiState.value.errorMessage)
}
@Test
fun `slash failure falls back with name arg and suppresses both error events`() =
runTest {
val (vm, sessionId) = createViewModelWithSession()
val calls = mutableListOf<String>()
val params = mutableListOf<Map<String, Any>>()
every { HermesWsClient.request(capture(calls), capture(params), any(), true) } answers {
if (arg<String>(0) == WsMethods.SLASH_EXEC) {
CompletableDeferred<Any?>().apply {
completeExceptionally(HermesWsClient.HermesRpcException("worker unavailable"))
}
} else {
CompletableDeferred<Any?>(mapOf("type" to "plugin", "output" to "plugin result"))
}
}
vm.sendMessage("/custom some args")
advanceUntilIdle()
assertEquals(listOf(WsMethods.SLASH_EXEC, WsMethods.COMMAND_DISPATCH), calls)
assertEquals(JsonPrimitive("custom"), params[1]["name"])
assertEquals(JsonPrimitive("some args"), params[1]["arg"])
assertEquals(JsonPrimitive(sessionId), params[1]["session_id"])
assertEquals(
"plugin result",
vm.uiState.value.messages
.last()
.content,
)
assertNull(vm.uiState.value.errorMessage)
}
@Test
fun `slash queue submits the arg with queued=true instead of command dispatch`() =
runTest {
@@ -235,9 +288,15 @@ class SlashCommandDispatchRpcTest {
val sentText = slot<String>()
val sentQueued = slot<Boolean>()
every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any(), any())
} answers {
CompletableDeferred<Any?>(Unit)
if (arg<String>(0) == WsMethods.SLASH_EXEC) {
CompletableDeferred<Any?>().apply {
completeExceptionally(HermesWsClient.HermesRpcException("slash unavailable"))
}
} else {
CompletableDeferred<Any?>(Unit)
}
}
every {
HermesWsClient.sendMessage(
@@ -372,9 +431,15 @@ class SlashCommandDispatchRpcTest {
val rpcMethods = mutableListOf<String>()
var sendCalls = 0
every {
HermesWsClient.request(capture(rpcMethods), any(), any())
HermesWsClient.request(capture(rpcMethods), any(), any(), any())
} answers {
CompletableDeferred<Any?>(Unit)
if (arg<String>(0) == WsMethods.SLASH_EXEC) {
CompletableDeferred<Any?>().apply {
completeExceptionally(HermesWsClient.HermesRpcException("slash unavailable"))
}
} else {
CompletableDeferred<Any?>(Unit)
}
}
every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers {
sendCalls++
@@ -416,12 +481,12 @@ class SlashCommandDispatchRpcTest {
}
@Test
fun `non-registry-miss backend error is surfaced directly (no fallback)`() =
fun `actionable fallback error is surfaced`() =
runTest {
val (vm, _) = createViewModelWithSession()
every {
HermesWsClient.request(any(), any(), any())
HermesWsClient.request(any(), any(), any(), any())
} answers {
val d = CompletableDeferred<Any?>()
d.completeExceptionally(
@@ -442,14 +507,14 @@ class SlashCommandDispatchRpcTest {
}
@Test
fun `registry-miss 4018 on command dispatch falls back to slash_exec and surfaces output`() =
fun `slash exec success surfaces output without command dispatch`() =
runTest {
val (vm, sessionId) = createViewModelWithSession()
val methodCalls = mutableListOf<String>()
val paramsCalls = mutableListOf<Map<String, Any>>()
every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any(), any())
} answers {
// Read the method from THIS invocation, not from the shared
// capture list: a concurrent request() landing between capture
@@ -474,16 +539,14 @@ class SlashCommandDispatchRpcTest {
vm.sendMessage("/status")
advanceUntilIdle()
// The fallback must have hit slash.exec with the full command string.
// Successful slash.exec must not need a fallback.
// Captures hold ALL requests — find the records instead of trusting
// "last" (create-flow session.usage can land after; capture race).
val dispatchIndex = methodCalls.indexOf(WsMethods.COMMAND_DISPATCH)
assertTrue("expected COMMAND_DISPATCH, got $methodCalls", dispatchIndex >= 0)
assertTrue(WsMethods.COMMAND_DISPATCH !in methodCalls)
val execIndex = methodCalls.indexOf(WsMethods.SLASH_EXEC)
assertTrue("expected SLASH_EXEC fallback, got $methodCalls", execIndex >= 0)
assertTrue("dispatch must precede fallback", dispatchIndex < execIndex)
assertTrue("expected SLASH_EXEC, got $methodCalls", execIndex >= 0)
val params = paramsCalls[execIndex]
assertEquals(JsonPrimitive("/status"), params["command"])
assertEquals(JsonPrimitive("status"), params["command"])
assertEquals(JsonPrimitive(sessionId), params["session_id"])
// And the slash.exec output must be surfaced to the user.
@@ -500,9 +563,15 @@ class SlashCommandDispatchRpcTest {
val rpcMethods = mutableListOf<String>()
every {
HermesWsClient.request(capture(rpcMethods), any(), any())
HermesWsClient.request(capture(rpcMethods), any(), any(), any())
} answers {
CompletableDeferred<Any?>(Unit)
if (arg<String>(0) == WsMethods.SLASH_EXEC) {
CompletableDeferred<Any?>().apply {
completeExceptionally(HermesWsClient.HermesRpcException("slash unavailable"))
}
} else {
CompletableDeferred<Any?>(Unit)
}
}
// /redraw is TUI-only (issue #574) — hidden from suggestions but a
@@ -533,12 +602,12 @@ class SlashCommandDispatchRpcTest {
}
@Test
fun `slash_exec double-fault surfaces the secondary error`() =
fun `fallback registry miss preserves original slash exec error`() =
runTest {
val (vm, _) = createViewModelWithSession()
every {
HermesWsClient.request(any(), any(), any())
HermesWsClient.request(any(), any(), any(), any())
} answers {
val m = arg<String>(0)
val d = CompletableDeferred<Any?>()
@@ -561,7 +630,7 @@ class SlashCommandDispatchRpcTest {
vm.sendMessage("/status")
advanceUntilIdle()
// Both RPCs fail -> the secondary slash.exec error must surface.
// A registry miss must not mask the original worker failure.
val last =
vm.uiState.value.messages
.lastOrNull()
@@ -569,12 +638,12 @@ class SlashCommandDispatchRpcTest {
}
@Test
fun `slash_exec blank output appends no assistant message`() =
fun `slash_exec blank output reports no output like desktop`() =
runTest {
val (vm, _) = createViewModelWithSession()
every {
HermesWsClient.request(any(), any(), any())
HermesWsClient.request(any(), any(), any(), any())
} answers {
val m = arg<String>(0)
val d = CompletableDeferred<Any?>()
@@ -595,12 +664,10 @@ class SlashCommandDispatchRpcTest {
vm.sendMessage("/status")
advanceUntilIdle()
// The user's "/status" message is added, but blank slash.exec output
// must NOT append an assistant bubble — so the last message is still
// the user's own command, and only one message was added.
assertEquals(before + 1, vm.uiState.value.messages.size)
// Desktop reports an explicit no-output notice rather than silently succeeding.
assertEquals(before + 2, vm.uiState.value.messages.size)
assertEquals(
"/status",
"/status: no output",
vm.uiState.value.messages
.lastOrNull()
?.content,
@@ -615,9 +682,15 @@ class SlashCommandDispatchRpcTest {
val methodCalls = mutableListOf<String>()
val paramsCalls = mutableListOf<Map<String, Any>>()
every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any(), any())
} answers {
CompletableDeferred<Any?>(Unit)
if (arg<String>(0) == WsMethods.SLASH_EXEC) {
CompletableDeferred<Any?>().apply {
completeExceptionally(HermesWsClient.HermesRpcException("slash unavailable"))
}
} else {
CompletableDeferred<Any?>(Unit)
}
}
// /init generates-or-updates AGENTS.md (backend command.dispatch
@@ -643,9 +716,15 @@ class SlashCommandDispatchRpcTest {
val methodCalls = mutableListOf<String>()
val paramsCalls = mutableListOf<Map<String, Any>>()
every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any(), any())
} answers {
CompletableDeferred<Any?>(Unit)
if (arg<String>(0) == WsMethods.SLASH_EXEC) {
CompletableDeferred<Any?>().apply {
completeExceptionally(HermesWsClient.HermesRpcException("slash unavailable"))
}
} else {
CompletableDeferred<Any?>(Unit)
}
}
vm.sendMessage("/init extra context here")
@@ -668,9 +747,15 @@ class SlashCommandDispatchRpcTest {
val methodCalls = mutableListOf<String>()
val paramsCalls = mutableListOf<Map<String, Any>>()
every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any(), any())
} answers {
CompletableDeferred<Any?>(Unit)
if (arg<String>(0) == WsMethods.SLASH_EXEC) {
CompletableDeferred<Any?>().apply {
completeExceptionally(HermesWsClient.HermesRpcException("slash unavailable"))
}
} else {
CompletableDeferred<Any?>(Unit)
}
}
// /focus is the display-only focus view (backend command.dispatch
@@ -696,9 +781,15 @@ class SlashCommandDispatchRpcTest {
val methodCalls = mutableListOf<String>()
val paramsCalls = mutableListOf<Map<String, Any>>()
every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any(), any())
} answers {
CompletableDeferred<Any?>(Unit)
if (arg<String>(0) == WsMethods.SLASH_EXEC) {
CompletableDeferred<Any?>().apply {
completeExceptionally(HermesWsClient.HermesRpcException("slash unavailable"))
}
} else {
CompletableDeferred<Any?>(Unit)
}
}
vm.sendMessage("/focus on")
@@ -714,7 +805,7 @@ class SlashCommandDispatchRpcTest {
}
@Test
fun `command dispatch type=send submits the returned prompt (init)`() =
fun `slash exec type=send submits the returned prompt (init)`() =
runTest {
val (vm, _) = createViewModelWithSession()
@@ -722,12 +813,12 @@ class SlashCommandDispatchRpcTest {
val paramsCalls = mutableListOf<Map<String, Any>>()
val sentText = slot<String>()
every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any(), any())
} answers {
val d = CompletableDeferred<Any?>()
// Read the method from THIS invocation, not from the shared
// capture list (see the 4018 test above).
if (arg<String>(0) == WsMethods.COMMAND_DISPATCH) {
if (arg<String>(0) == WsMethods.SLASH_EXEC) {
// Backend /init returns type:"send" with the AGENTS.md prompt.
d.complete(mapOf("type" to "send", "message" to "Scan this repo and write AGENTS.md"))
} else {
@@ -766,7 +857,7 @@ class SlashCommandDispatchRpcTest {
val (vm, _) = createViewModelWithSession()
every {
HermesWsClient.request(any(), any(), any())
HermesWsClient.request(any(), any(), any(), any())
} answers {
val m = arg<String>(0)
val d = CompletableDeferred<Any?>()
@@ -774,7 +865,7 @@ class SlashCommandDispatchRpcTest {
// Backend /focus returns type:"exec" with a notice line.
d.complete(mapOf("type" to "exec", "output" to "Focus view: ON (tool progress pinned off)"))
} else {
d.complete(mapOf("session_id" to "session-xyz"))
d.completeExceptionally(HermesWsClient.HermesRpcException("slash unavailable"))
}
d
}
@@ -0,0 +1,355 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.local.toEntity
import com.m57.hermescontrol.data.local.toUiModel
import com.m57.hermescontrol.data.model.AttachmentSource
import com.m57.hermescontrol.data.model.SessionMessage
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Regression tests for user image hydration:
* - Direct unit tests for [userImageAttachments]
* - Integration via [mapServerMessages] with USER turns
*/
class UserImageHydrationTest {
@Test
fun restHydrationEnrichesCachedUserWithoutReplacingLocalAttachments() {
val rest =
mapServerMessages(
sessionId = "session-1",
messages =
listOf(
SessionMessage(id = 42, role = "user", content = JsonPrimitive("caption\n@image:/image.png")),
),
offset = 0,
latestPaging = true,
liveMessages = emptyList(),
mediaUrl = { "https://gateway.test/image.png" },
).single()
val cached =
rest.copy(
attachments = null,
content = "[{\"image_url\":\"data:image/png;base64,old\"}]",
isHistoricalCache = true,
)
val hydrated = mergeTranscriptWithLive(listOf(rest), listOf(cached)).single()
assertEquals(rest.attachments, hydrated.attachments)
assertEquals(rest.content, hydrated.content)
assertEquals(cached.id, hydrated.id)
val localAttachments =
listOf(
com.m57.hermescontrol.data.model
.Attachment("content://local/image", "image.png", "image/png"),
)
val local = rest.copy(attachments = localAttachments)
assertEquals(localAttachments, mergeTranscriptWithLive(listOf(rest), listOf(local)).single().attachments)
}
@Test
fun roomRoundTripKeepsReferencesForGatewayReconstruction() {
val content = "caption\n@image:/image.png"
val message = ChatMessage(id = "cached", role = MessageRole.USER, content = content)
val restored = message.toEntity("session-1").toUiModel()
assertEquals(content, restored.content)
val attachments = userImageAttachments(restored.content) { "https://current-gateway.test$it" }
assertEquals("https://current-gateway.test/image.png", attachments.single().gatewayUrl)
}
@Test
fun userImageAttachments_recognizesTrimmedWholeLineImageDirectiveWithAbsolutePaths() {
val content =
"""
Here is my photo
@image:/absolute/path/to/cat.png
And another one
@image:/absolute/path/to/dog.jpg
""".trimIndent()
val attachments =
userImageAttachments(content) { path -> "https://gateway.local/api/files/download?path=$path" }
assertEquals(2, attachments.size)
val first = attachments[0]
assertEquals("https://gateway.local/api/files/download?path=/absolute/path/to/cat.png", first.uri)
assertEquals("cat.png", first.name)
assertEquals("image/png", first.mimeType)
assertEquals(0L, first.size)
assertEquals("https://gateway.local/api/files/download?path=/absolute/path/to/cat.png", first.gatewayUrl)
assertEquals(AttachmentSource.GATEWAY, first.source)
assertTrue(first.isImage)
val second = attachments[1]
assertEquals("https://gateway.local/api/files/download?path=/absolute/path/to/dog.jpg", second.uri)
assertEquals("dog.jpg", second.name)
assertEquals("image/jpeg", second.mimeType)
assertEquals(AttachmentSource.GATEWAY, second.source)
assertTrue(second.isImage)
}
@Test
fun userImageAttachments_allowsSpacesInAbsolutePaths() {
val content = "@image:/Users/test user/My Documents/holiday photo 2026.png"
val attachments =
userImageAttachments(content) { path -> "https://gateway.local/api/files/download?path=$path" }
assertEquals(1, attachments.size)
val attachment = attachments.single()
assertEquals(
"https://gateway.local/api/files/download?path=/Users/test user/My Documents/holiday photo 2026.png",
attachment.uri,
)
assertEquals("holiday photo 2026.png", attachment.name)
assertEquals("image/png", attachment.mimeType)
assertEquals(AttachmentSource.GATEWAY, attachment.source)
}
@Test
fun userImageAttachments_deduplicatesRepeatPathsKeepingFirst() {
val content =
"""
First mention
@image:/images/duplicated.png
Second mention
@image:/images/duplicated.png
Third mention with whitespace padding
@image:/images/duplicated.png
Different image
@image:/images/unique.png
""".trimIndent()
val attachments = userImageAttachments(content) { path -> "https://gateway.local/file?p=$path" }
assertEquals(2, attachments.size)
assertEquals("https://gateway.local/file?p=/images/duplicated.png", attachments[0].uri)
assertEquals("duplicated.png", attachments[0].name)
assertEquals("https://gateway.local/file?p=/images/unique.png", attachments[1].uri)
assertEquals("unique.png", attachments[1].name)
}
@Test
fun userImageAttachments_ignoresInlineProseEmptyAndRelativeReferences() {
val content =
"""
Do not match inline prose such as check out @image:/absolute/path/foo.png in middle of text
Do not match trailing inline text @image:/path/bar.png extra
Do not match relative path @image:relative/path/image.png
@image:
@image:
@image:relative/foo.jpg
@image:./local/foo.png
@image:../parent/foo.png
@image:not-an-absolute-path.jpg
Valid absolute path at start of line:
@image:/valid/match.png
""".trimIndent()
val attachments = userImageAttachments(content) { path -> "http://gateway.test$path" }
assertEquals(1, attachments.size)
assertEquals("http://gateway.test/valid/match.png", attachments.single().uri)
assertEquals("match.png", attachments.single().name)
}
@Test
fun userImageAttachments_whenCallbackReturnsNull_returnsNoAttachments() {
val content =
"""
@image:/valid/photo1.jpg
@image:/valid/photo2.png
""".trimIndent()
val attachments = userImageAttachments(content) { null }
assertTrue(attachments.isEmpty())
}
@Test
fun userImageAttachments_whenContentHasNoImageDirectives_returnsEmptyList() {
val content = "Just a regular text conversation with no images attached."
val attachments = userImageAttachments(content) { path -> "https://gw/$path" }
assertTrue(attachments.isEmpty())
}
@Test
fun mapServerMessages_withUserTurn_attachesGatewayImageFromPlainStringContent() {
val userRow =
SessionMessage(
id = 1,
role = "user",
content = JsonPrimitive("Please review this design\n@image:/data/mockups/home.png"),
)
val mapped =
mapServerMessages(
sessionId = "session-1",
messages = listOf(userRow),
offset = 0,
latestPaging = true,
liveMessages = emptyList(),
mediaUrl = { path -> "https://gateway.local/files?p=$path" },
)
assertEquals(1, mapped.size)
val chatMessage = mapped.single()
assertEquals(MessageRole.USER, chatMessage.role)
assertTrue(chatMessage.content.contains("Please review this design"))
assertTrue(chatMessage.content.contains("@image:/data/mockups/home.png"))
val attachments = chatMessage.attachments
assertNotNull(attachments)
assertEquals(1, attachments!!.size)
val att = attachments.single()
assertEquals("https://gateway.local/files?p=/data/mockups/home.png", att.uri)
assertEquals("home.png", att.name)
assertEquals("image/png", att.mimeType)
assertEquals(AttachmentSource.GATEWAY, att.source)
assertTrue(att.isGateway)
}
@Test
fun mapServerMessages_withUserMultipart748kPayload_preservesCaptionAndNoBase64InContent() {
val largeBase64 = "data:image/png;base64," + "iVBORw0KGgoAAAANSUhEUgAA".repeat(31_000) // ~748k chars
val caption = "Check out this screenshot\n@image:/opt/data/images/screen.png"
val multipart =
buildJsonArray {
add(
buildJsonObject {
put("type", "image_url")
putJsonObject("image_url") {
put("url", largeBase64)
}
},
)
add(
buildJsonObject {
put("type", "text")
put("text", caption)
},
)
}
val userRow =
SessionMessage(
id = 42,
role = "user",
content = multipart,
)
val mapped =
mapServerMessages(
sessionId = "session-1",
messages = listOf(userRow),
offset = 0,
latestPaging = true,
liveMessages = emptyList(),
mediaUrl = { path -> "https://gw.internal/download?path=$path" },
)
assertEquals(1, mapped.size)
val chatMessage = mapped.single()
// Content must preserve the caption and reference text, but contain no base64 payload
assertTrue(chatMessage.content.contains("Check out this screenshot"))
assertTrue(chatMessage.content.contains("@image:/opt/data/images/screen.png"))
assertFalse(chatMessage.content.contains("data:image/png;base64"))
assertFalse(chatMessage.content.contains("iVBORw0KGgoAAAANSUhEUgAA"))
// Attachment hydrated from @image directive
val attachments = chatMessage.attachments
assertNotNull(attachments)
assertEquals(1, attachments!!.size)
val att = attachments.single()
assertEquals("https://gw.internal/download?path=/opt/data/images/screen.png", att.uri)
assertEquals("screen.png", att.name)
assertEquals("image/png", att.mimeType)
assertEquals(AttachmentSource.GATEWAY, att.source)
}
@Test
fun mapServerMessages_withUserTurn_deduplicatesRepeatImagePaths() {
val content =
"""
Examining these photos:
@image:/data/photos/target.jpg
@image:/data/photos/target.jpg
@image:/data/photos/target.jpg
@image:/data/photos/another.png
""".trimIndent()
val userRow =
SessionMessage(
id = 5,
role = "user",
content = JsonPrimitive(content),
)
val mapped =
mapServerMessages(
sessionId = "session-1",
messages = listOf(userRow),
offset = 0,
latestPaging = true,
liveMessages = emptyList(),
mediaUrl = { path -> "https://gw.internal$path" },
)
val chatMessage = mapped.single()
val attachments = chatMessage.attachments
assertNotNull(attachments)
assertEquals(2, attachments!!.size)
assertEquals("https://gw.internal/data/photos/target.jpg", attachments[0].uri)
assertEquals("target.jpg", attachments[0].name)
assertEquals("https://gw.internal/data/photos/another.png", attachments[1].uri)
assertEquals("another.png", attachments[1].name)
}
@Test
fun mapServerMessages_withUserTurn_whenGatewayUnavailable_preservesCaptionAndAddsNoAttachments() {
val caption = "Look at this error log\n@image:/var/log/screenshot.png"
val userRow =
SessionMessage(
id = 10,
role = "user",
content = JsonPrimitive(caption),
)
val mapped =
mapServerMessages(
sessionId = "session-1",
messages = listOf(userRow),
offset = 0,
latestPaging = true,
liveMessages = emptyList(),
mediaUrl = { null }, // Gateway unavailable
)
val chatMessage = mapped.single()
assertTrue(chatMessage.content.contains("Look at this error log"))
assertTrue(chatMessage.content.contains("@image:/var/log/screenshot.png"))
assertNull(chatMessage.attachments)
}
@Test
fun hideImageRefLines_removesOnlyPathLinesAndKeepsCaption() {
val content = "caption\n@image:/opt/data/images/a.jpg\nmore text @image:/inline.png"
assertEquals("caption\nmore text @image:/inline.png", hideImageRefLines(content))
assertEquals("plain", hideImageRefLines("plain"))
}
}
@@ -0,0 +1,342 @@
package com.m57.hermescontrol.ui.chat.components
import android.net.Uri
import androidx.compose.foundation.ExperimentalFoundationApi
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.ui.chat.ChatAttachmentTarget
import com.m57.hermescontrol.ui.chat.PastedImage
import com.m57.hermescontrol.ui.chat.PastedImageTooLargeException
import io.mockk.mockk
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.job
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.RandomAccessFile
@OptIn(ExperimentalFoundationApi::class, ExperimentalCoroutinesApi::class)
class ChatImagePasteControllerTest {
@get:Rule val temp = TemporaryFolder()
private val target = ChatAttachmentTarget("session", 1, "http://test.local", null, null)
private fun image(name: String): PastedImage {
val file = temp.newFile(name).apply { writeBytes(byteArrayOf(1, 2)) }
return PastedImage(Attachment("content://test/$name", name, "image/png", file.length()), file)
}
@Test
fun successPublishesAnOrderedBatchOnlyAfterEveryImageIsCopied() =
runTest {
val first = image("one.png")
val second = image("two.png")
val gate = CompletableDeferred<Unit>()
val committed = mutableListOf<Attachment>()
val errors = mutableListOf<Int>()
var count = 0
val controller =
ChatImagePasteController(
this,
importImage = { _, _ ->
if (count++ == 0) {
first
} else {
gate.await()
second
}
},
captureTarget = { target },
commit = { _, attachments ->
committed.addAll(attachments)
true
},
onError = errors::add,
)
assertTrue(controller.receiveImages(listOf(mockk<Uri>(), mockk<Uri>()), Any()))
assertTrue(controller.isReceiving)
runCurrent()
assertTrue(committed.isEmpty())
gate.complete(Unit)
coroutineContext.job.children
.toList()
.joinAll()
assertFalse(controller.isReceiving)
assertEquals(listOf(first.attachment, second.attachment), committed)
assertTrue(first.file.exists() && second.file.exists())
assertTrue(errors.isEmpty())
}
@Test
fun laterUnreadableImageRollsBackTheEntireBatch() =
runTest {
val first = image("rollback.png")
var count = 0
var commits = 0
val errors = mutableListOf<Int>()
val controller =
ChatImagePasteController(
this,
importImage = { _, _ -> if (count++ == 0) first else null },
captureTarget = { target },
commit = { _, _ ->
commits++
true
},
onError = errors::add,
)
controller.receiveImages(listOf(mockk<Uri>(), mockk<Uri>()), Any())
coroutineContext.job.children
.toList()
.joinAll()
assertEquals(0, commits)
assertFalse(first.file.exists())
assertEquals(listOf(R.string.chat_image_paste_failed), errors)
assertFalse(controller.isReceiving)
}
@Test
fun sessionChangeDuringCopyDiscardsImageWithoutTouchingTheNewChat() =
runTest {
val result = image("stale.png")
val gate = CompletableDeferred<Unit>()
var owner = target
var commits = 0
val errors = mutableListOf<Int>()
val controller =
ChatImagePasteController(
this,
importImage = { _, _ ->
gate.await()
result
},
captureTarget = { owner },
commit = { _, _ ->
commits++
true
},
onError = errors::add,
)
controller.receiveImages(listOf(mockk<Uri>()), Any())
runCurrent()
owner = target.copy(generation = 3)
gate.complete(Unit)
coroutineContext.job.children
.toList()
.joinAll()
assertEquals(0, commits)
assertFalse(result.file.exists())
assertTrue(errors.isEmpty())
}
@Test
fun disposalCancelsCopyAndDeletesOnlyUncommittedFiles() =
runTest {
val first = image("cancel.png")
var count = 0
var commits = 0
val errors = mutableListOf<Int>()
val controller =
ChatImagePasteController(
this,
importImage = { _, _ -> if (count++ == 0) first else awaitCancellation() },
captureTarget = { target },
commit = { _, _ ->
commits++
true
},
onError = errors::add,
)
controller.receiveImages(listOf(mockk<Uri>(), mockk<Uri>()), Any())
runCurrent()
controller.close()
coroutineContext.job.children
.toList()
.joinAll()
assertFalse(first.file.exists())
assertEquals(0, commits)
assertTrue(errors.isEmpty())
assertFalse(controller.receiveImages(listOf(mockk<Uri>()), Any()))
}
@Test
fun unreadySessionNeverOpensTheProvider() =
runTest {
var reads = 0
val errors = mutableListOf<Int>()
val controller =
ChatImagePasteController(
this,
importImage = { _, _ ->
reads++
null
},
captureTarget = { null },
commit = { _, _ -> error("Must not commit") },
onError = errors::add,
)
assertTrue(controller.receiveImages(listOf(mockk<Uri>()), Any()))
assertEquals(0, reads)
assertEquals(listOf(R.string.chat_session_not_ready), errors)
assertFalse(controller.isReceiving)
}
@Test
fun secondPasteWhileImportingDoesNotStartAnotherBatch() =
runTest {
val result = image("busy.png")
val gate = CompletableDeferred<Unit>()
var reads = 0
val errors = mutableListOf<Int>()
val controller =
ChatImagePasteController(
this,
importImage = { _, _ ->
reads++
gate.await()
result
},
captureTarget = { target },
commit = { _, _ -> true },
onError = errors::add,
)
controller.receiveImages(listOf(mockk<Uri>()), Any())
controller.receiveImages(listOf(mockk<Uri>()), Any())
gate.complete(Unit)
coroutineContext.job.children
.toList()
.joinAll()
assertEquals(1, reads)
assertEquals(listOf(R.string.chat_image_paste_busy), errors)
}
@Test
fun ninthImageRejectsBeforeOpeningAnyProvider() =
runTest {
var reads = 0
val errors = mutableListOf<Int>()
val controller =
ChatImagePasteController(
this,
importImage = { _, _ ->
reads++
null
},
captureTarget = { target },
commit = { _, _ -> error("Must not commit") },
onError = errors::add,
)
assertTrue(controller.receiveImages(List(9) { mockk<Uri>() }, Any()))
runCurrent()
assertEquals(0, reads)
assertEquals(listOf(R.string.chat_image_paste_batch_limit), errors)
assertFalse(controller.isReceiving)
}
@Test
fun exactBatchLimitKeepsQueuedFilesAndPassesRemainingBudget() =
runTest {
val first = image("first-large.png")
val second = image("second-large.png")
listOf(first, second).forEach {
RandomAccessFile(it.file, "rw").use { file ->
file.setLength(10L * 1024 * 1024)
}
}
val caps = mutableListOf<Long>()
val committed = mutableListOf<Attachment>()
val controller =
ChatImagePasteController(
this,
importImage = { _, cap ->
caps += cap
if (caps.size == 1) first else second
},
captureTarget = { target },
commit = { _, attachments ->
committed.addAll(attachments)
true
},
onError = { error("Unexpected error: $it") },
)
controller.receiveImages(List(2) { mockk<Uri>() }, Any())
runCurrent()
assertEquals(listOf(10L * 1024 * 1024, 10L * 1024 * 1024), caps)
assertEquals(listOf(first.attachment, second.attachment), committed)
assertTrue(first.file.exists() && second.file.exists())
}
@Test
fun exhaustedBudgetRejectsNextWithoutIoAndRollsBack() =
runTest {
val first = image("full-a.png")
val second = image("full-b.png")
listOf(first, second).forEach {
RandomAccessFile(it.file, "rw").use { file ->
file.setLength(10L * 1024 * 1024)
}
}
var reads = 0
var commits = 0
val errors = mutableListOf<Int>()
val controller =
ChatImagePasteController(
this,
importImage = { _, _ -> if (++reads == 1) first else second },
captureTarget = { target },
commit = { _, _ ->
commits++
true
},
onError = errors::add,
)
controller.receiveImages(List(3) { mockk<Uri>() }, Any())
coroutineContext.job.children
.toList()
.joinAll()
assertEquals(2, reads)
assertEquals(0, commits)
assertFalse(first.file.exists() || second.file.exists())
assertEquals(listOf(R.string.chat_image_paste_batch_limit), errors)
}
@Test
fun remainingBudgetOverflowReportsBatchLimitAndCleansUp() =
runTest {
val first = image("partial-a.png")
val second = image("partial-b.png")
RandomAccessFile(first.file, "rw").use { it.setLength(10L * 1024 * 1024) }
RandomAccessFile(second.file, "rw").use { it.setLength(2L * 1024 * 1024) }
val caps = mutableListOf<Long>()
val errors = mutableListOf<Int>()
val controller =
ChatImagePasteController(
this,
importImage = { _, cap ->
caps += cap
when (caps.size) {
1 -> first
2 -> second
else -> throw PastedImageTooLargeException()
}
},
captureTarget = { target },
commit = { _, _ -> error("Must not commit") },
onError = errors::add,
)
controller.receiveImages(List(3) { mockk<Uri>() }, Any())
coroutineContext.job.children
.toList()
.joinAll()
assertEquals(listOf(10L * 1024 * 1024, 10L * 1024 * 1024, 8L * 1024 * 1024), caps)
assertFalse(first.file.exists() || second.file.exists())
assertEquals(listOf(R.string.chat_image_paste_batch_limit), errors)
}
}
@@ -10,6 +10,9 @@ import com.m57.hermescontrol.ui.chat.MessageRole
import com.m57.hermescontrol.ui.chat.PendingSend
import com.m57.hermescontrol.ui.chat.PendingSendState
import com.m57.hermescontrol.ui.chat.StreamingState
import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
@@ -22,6 +25,54 @@ class TranscriptUiStateTest {
private val historical = ChatMessage(id = "historical", role = MessageRole.USER, content = "older")
private val clarify = ClarifyUi(text = "choose")
@Test
fun emptyStateDoesNotHideOtherRenderableTailContent() {
val empty = TranscriptUiState.resolve(ChatUiState(), ChatTimelineState(), StreamingState(), null, null)
assertTrue(shouldShowChatEmptyState(empty, hasReplyError = false))
val visibleStates =
listOf(
empty.copy(vaultUnlockPrompt = VaultUnlockPromptUi("unlock", "session")),
empty.copy(vaultSaveLoginPrompt = VaultSaveLoginPromptUi("save", "session")),
empty.copy(vaultCodePrompt = VaultCodePromptUi("code", "session")),
empty.copy(isCompressing = true),
empty.copy(compressionStatus = "Compressing"),
empty.copy(streamingState = StreamingState(streamingMessage = live)),
empty.copy(isAgentTyping = true),
empty.copy(isLoading = true),
empty.copy(messages = listOf(live)),
)
visibleStates.forEach { assertFalse(shouldShowChatEmptyState(it, hasReplyError = false)) }
assertFalse(shouldShowChatEmptyState(empty, hasReplyError = true))
}
@Test
fun `clarify on empty live transcript uses list rather than empty state`() {
val transcript =
TranscriptUiState.resolve(
ChatUiState(currentSessionId = "session", clarifyRequest = clarify),
ChatTimelineState(),
StreamingState(),
null,
null,
)
assertSame(clarify, transcript.clarifyRequest)
assertFalse(shouldShowChatEmptyState(transcript, hasReplyError = false))
}
@Test
fun `empty historical snapshot remains empty without displaying live clarify`() {
val transcript =
TranscriptUiState.resolve(
ChatUiState(currentSessionId = "session", clarifyRequest = clarify),
ChatTimelineState(historyMessages = emptyList()),
StreamingState(),
null,
null,
)
assertNull(transcript.clarifyRequest)
assertTrue(shouldShowChatEmptyState(transcript, hasReplyError = false))
}
@Test
fun acceptedLivePromptKeepsItsTextAndImageUntilHistoryConfirmsIt() {
val image = Attachment("file:///private/photo", "photo.png", "image/png", 3)