chore: promote dev to main (#1455)
Co-authored-by: AlexDev_ <56083016+alexdev03@users.noreply.github.com>
This commit is contained in:
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"),
|
||||
)
|
||||
|
||||
+17
-11
@@ -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)
|
||||
|
||||
+7
-1
@@ -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"))
|
||||
}
|
||||
}
|
||||
+342
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user