release: promote v1.31.0-rc.2 to main (#1354)

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Lx-204 <15377891490@163.com>
Co-authored-by: AlexDev_ <56083016+alexdev03@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Nikhil Nair <32665919+nikhilnair31@users.noreply.github.com>
Co-authored-by: marciodps <95321123+marciodps@users.noreply.github.com>
Co-authored-by: ThomasCrund <39323104+ThomasCrund@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-09-28 12:39:34 +03:00
1 parent e4004decb4
commit ed915d0816
111 files changed
+10563 -998

No files matched your search

@@ -35,6 +35,7 @@ class AuthManagerTest {
@Before
fun setUp() {
mockPrefs = mockk(relaxed = true)
every { mockPrefs.getString("server_custom_headers", null) } returns null
mockEditor = mockk(relaxed = true)
mockContext = mockk(relaxed = true)
@@ -46,6 +46,7 @@ class Issue647ProfileUrlTest {
@Before
fun setUp() {
mockPrefs = mockk(relaxed = true)
every { mockPrefs.getString("server_custom_headers", null) } returns null
mockEditor = mockk(relaxed = true)
mockContext = mockk(relaxed = true)
@@ -0,0 +1,140 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class TtsTest {
private val json =
Json {
ignoreUnknownKeys = true
isLenient = true
}
@Test
fun `serialize TtsSpeakRequest with literal text`() {
val request = TtsSpeakRequest(text = "Hello, world!")
val encoded = json.encodeToString(request)
assertEquals("""{"text":"Hello, world!"}""", encoded)
}
@Test
fun `deserialize TtsSpeakResponse with all fields present`() {
val rawJson =
"""
{
"ok": true,
"data_url": "data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=",
"mime_type": "audio/wav",
"provider": "piper"
}
""".trimIndent()
val response = json.decodeFromString<TtsSpeakResponse>(rawJson)
assertTrue(response.ok)
assertEquals(
"data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=",
response.data_url,
)
assertEquals("audio/wav", response.mime_type)
assertEquals("piper", response.provider)
}
@Test
fun `deserialize TtsSpeakResponse with optional provider missing`() {
val rawJson =
"""
{
"ok": true,
"data_url": "data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYwLjMuMTAwAAAAAAAAAAAAAAD/",
"mime_type": "audio/mp3"
}
""".trimIndent()
val response = json.decodeFromString<TtsSpeakResponse>(rawJson)
assertTrue(response.ok)
assertEquals(
"data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYwLjMuMTAwAAAAAAAAAAAAAAD/",
response.data_url,
)
assertEquals("audio/mp3", response.mime_type)
assertNull(response.provider)
}
@Test
fun `deserialize TtsSpeakResponse with provider explicitly null`() {
val rawJson =
"""
{
"ok": true,
"data_url": "data:audio/ogg;base64,T2dnUwACAAAAAAAAAAA=",
"mime_type": "audio/ogg",
"provider": null
}
""".trimIndent()
val response = json.decodeFromString<TtsSpeakResponse>(rawJson)
assertTrue(response.ok)
assertEquals("data:audio/ogg;base64,T2dnUwACAAAAAAAAAAA=", response.data_url)
assertEquals("audio/ogg", response.mime_type)
assertNull(response.provider)
}
@Test
fun `deserialize TtsSpeakResponse with missing ok defaults to false`() {
val rawJson =
"""
{
"data_url": null,
"mime_type": null
}
""".trimIndent()
val response = json.decodeFromString<TtsSpeakResponse>(rawJson)
assertFalse(response.ok)
assertNull(response.data_url)
assertNull(response.mime_type)
assertNull(response.provider)
}
@Test
fun `deserialize TtsSpeakResponse with empty json defaults to ok false and nulls`() {
val rawJson = "{}"
val response = json.decodeFromString<TtsSpeakResponse>(rawJson)
assertFalse(response.ok)
assertNull(response.data_url)
assertNull(response.mime_type)
assertNull(response.provider)
}
@Test
fun `deserialize TtsSpeakResponse with unknown fields ignores extra properties`() {
val rawJson =
"""
{
"ok": false,
"error": "Provider not reachable",
"status_code": 500
}
""".trimIndent()
val response = json.decodeFromString<TtsSpeakResponse>(rawJson)
assertFalse(response.ok)
assertNull(response.data_url)
assertNull(response.mime_type)
assertNull(response.provider)
}
}
@@ -0,0 +1,297 @@
package com.m57.hermescontrol.data.remote
import android.content.SharedPreferences
import io.mockk.every
import io.mockk.mockk
import okhttp3.CookieJar
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.io.IOException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
class ServerHeadersTest {
private val preferences = mockk<SharedPreferences>()
private val editor = mockk<SharedPreferences.Editor>()
private var saved: String? = null
private lateinit var server: MockWebServer
private val headers =
CustomHeaders.parse(
listOf(
"CF-Access-Client-Id" to "test-id",
"CF-Access-Client-Secret" to "secret",
),
)
@Before
fun setUp() {
every { preferences.getString(any(), null) } answers { saved }
every { preferences.edit() } returns editor
every { editor.putString(any(), any()) } answers {
saved = secondArg()
editor
}
every { editor.commit() } returns true
ServerHeaders.initialize(preferences)
CookieManager.setJarForTest(buildFakePersistentCookieJar())
server = MockWebServer()
server.start()
}
@After
fun tearDown() {
saved = null
ServerHeaders.initialize(preferences)
server.shutdown()
}
@Test
fun `protected probe login ticket API and media requests require both headers`() {
server.dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse =
if (request.getHeader("CF-Access-Client-Id") == "test-id" &&
request.getHeader("CF-Access-Client-Secret") == "secret"
) {
MockResponse().setBody("{}")
} else {
MockResponse().setResponseCode(403)
}
}
val url = server.url("/hermes/")
val probe = Request.Builder().url(server.url("/hermes/api/status")).build()
OkHttpProvider.probe
.newCall(probe)
.execute()
.use { assertEquals(403, it.code) }
ServerHeaders.save(url, headers)
val paths = listOf("api/status", "auth/password-login", "api/auth/ws-ticket", "api/sessions", "files/image.png")
paths.forEach { path ->
val builder = Request.Builder().url(server.url("/hermes/$path"))
if (path.contains("login") || path.contains("ticket")) builder.post("{}".toRequestBody())
listOf(OkHttpProvider.probe, OkHttpProvider.base).forEach { client ->
client.newCall(builder.build()).execute().use { assertEquals(200, it.code) }
}
}
ServerHeaders.save(url, CustomHeaders.EMPTY)
OkHttpProvider.probe
.newCall(probe)
.execute()
.use { assertEquals(403, it.code) }
}
@Test
fun `custom headers coexist with Hermes bearer and cookie authentication`() {
ServerHeaders.save(server.url("/"), headers)
server.enqueue(MockResponse())
val request =
Request
.Builder()
.url(server.url("/api/status"))
.header("Authorization", "Bearer hermes-token")
.header("Cookie", "hermes_session_at=session")
.build()
OkHttpProvider.base
.newBuilder()
.cookieJar(CookieJar.NO_COOKIES)
.build()
.newCall(request)
.execute()
.close()
val recorded = server.takeRequest()
assertEquals("test-id", recorded.getHeader("CF-Access-Client-Id"))
assertEquals("Bearer hermes-token", recorded.getHeader("Authorization"))
assertEquals("hermes_session_at=session", recorded.getHeader("Cookie"))
}
@Test
fun `WebSocket upgrade carries custom headers and receives a message`() {
ServerHeaders.save(server.url("/hermes/"), headers)
val message = CountDownLatch(1)
server.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) {
webSocket.send("connected")
}
},
),
)
val socket =
OkHttpProvider.websocket.newWebSocket(
Request.Builder().url(server.url("/hermes/api/ws?ticket=test")).build(),
object : WebSocketListener() {
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
if (text == "connected") message.countDown()
}
},
)
try {
assertTrue(message.await(5, TimeUnit.SECONDS))
val recorded = server.takeRequest()
assertEquals("test-id", recorded.getHeader("CF-Access-Client-Id"))
assertEquals("secret", recorded.getHeader("CF-Access-Client-Secret"))
assertEquals("websocket", recorded.getHeader("Upgrade"))
} finally {
socket.cancel()
}
}
@Test
fun `HTTP redirect to another configured server sends neither servers credentials`() {
MockWebServer().use { other ->
other.start()
ServerHeaders.save(server.url("/"), headers)
ServerHeaders.save(other.url("/"), CustomHeaders.parse(listOf("X-Other-Secret" to "other-secret")))
server.enqueue(MockResponse().setResponseCode(302).setHeader("Location", other.url("/api/status")))
other.enqueue(MockResponse())
OkHttpProvider.base
.newCall(Request.Builder().url(server.url("/api/status")).build())
.execute()
.close()
assertEquals("secret", server.takeRequest().getHeader("CF-Access-Client-Secret"))
val redirected = other.takeRequest()
assertNull(redirected.getHeader("CF-Access-Client-Secret"))
assertNull(redirected.getHeader("CF-Access-Client-Id"))
assertNull(redirected.getHeader("X-Other-Secret"))
}
}
@Test
fun `same-server redirect preserves headers but escaping path prefix removes them`() {
ServerHeaders.save(server.url("/hermes/"), headers)
server.enqueue(MockResponse().setResponseCode(302).setHeader("Location", server.url("/hermes/next")))
server.enqueue(MockResponse().setResponseCode(302).setHeader("Location", server.url("/outside")))
server.enqueue(MockResponse())
OkHttpProvider.base
.newCall(Request.Builder().url(server.url("/hermes/start")).build())
.execute()
.close()
assertEquals("secret", server.takeRequest().getHeader("CF-Access-Client-Secret"))
assertEquals("secret", server.takeRequest().getHeader("CF-Access-Client-Secret"))
assertNull(server.takeRequest().getHeader("CF-Access-Client-Secret"))
}
@Test
fun `redirect into a separately configured path does not inherit parent credentials`() {
ServerHeaders.save(server.url("/"), headers)
ServerHeaders.save(server.url("/public/"), CustomHeaders.EMPTY)
server.enqueue(MockResponse().setResponseCode(302).setHeader("Location", server.url("/public/api/status")))
server.enqueue(MockResponse())
OkHttpProvider.base
.newCall(Request.Builder().url(server.url("/start")).build())
.execute()
.close()
assertEquals("secret", server.takeRequest().getHeader("CF-Access-Client-Secret"))
assertNull(server.takeRequest().getHeader("CF-Access-Client-Secret"))
}
@Test
fun `WebSocket redirect is not followed`() {
MockWebServer().use { other ->
other.start()
ServerHeaders.save(server.url("/"), headers)
server.enqueue(MockResponse().setResponseCode(302).setHeader("Location", other.url("/api/ws")))
val failed = CountDownLatch(1)
val socket =
OkHttpProvider.websocket.newWebSocket(
Request.Builder().url(server.url("/api/ws")).build(),
object : WebSocketListener() {
override fun onFailure(
webSocket: WebSocket,
t: Throwable,
response: Response?,
) {
if (response?.code == 302) failed.countDown()
}
},
)
try {
assertTrue(failed.await(5, TimeUnit.SECONDS))
assertEquals(0, other.requestCount)
} finally {
socket.cancel()
}
}
}
@Test
fun `scope matches scheme host port and complete path segment`() {
val scope = ScopedServerHeaders("https://hermes.example/app/".toHttpUrl(), headers)
assertTrue(scope.contains("https://hermes.example/app/api/status".toHttpUrl()))
assertTrue(scope.contains("https://hermes.example/app".toHttpUrl()))
listOf(
"http://hermes.example/app/api/status",
"https://other.example/app/api/status",
"https://hermes.example:8443/app/api/status",
"https://hermes.example/application/api/status",
).forEach { assertFalse(scope.contains(it.toHttpUrl())) }
}
@Test
fun `saved credentials survive reload and an empty child scope blocks parent credentials`() {
ServerHeaders.save(server.url("/"), headers)
ServerHeaders.save(server.url("/public/"), CustomHeaders.EMPTY)
ServerHeaders.initialize(preferences)
assertEquals("secret", ServerHeaders.get(server.url("/")).entries["CF-Access-Client-Secret"])
assertTrue(
ServerHeaders
.forRequest(server.url("/public/api/status"))!!
.headers.entries
.isEmpty(),
)
}
@Test
fun `failed storage write does not publish new credentials`() {
every { editor.commit() } returns false
assertThrows(IOException::class.java) { ServerHeaders.save(server.url("/"), headers) }
assertTrue(ServerHeaders.get(server.url("/")).entries.isEmpty())
}
@Test
fun `malformed stored JSON does not expose credentials in errors`() {
saved = "{\"secret\": broken"
val error = assertThrows(IOException::class.java) { ServerHeaders.initialize(preferences) }
assertFalse(error.stackTraceToString().contains("secret"))
}
@Test
fun `reject invalid duplicate and managed headers without revealing values`() {
listOf(
listOf("Bad Name" to "secret"),
listOf("X-Test" to "secret\r\nInjected: bad"),
listOf("X-Test" to "secret", "x-test" to "secret"),
listOf("Authorization" to "secret"),
listOf("Cookie" to "secret"),
listOf("Host" to "secret"),
listOf("Sec-WebSocket-Key" to "secret"),
).forEach { rows ->
val error = assertThrows(IllegalArgumentException::class.java) { CustomHeaders.parse(rows) }
assertFalse(error.message.orEmpty().contains("secret"))
}
assertFalse(headers.toString().contains("secret"))
}
}
@@ -16,7 +16,9 @@ import org.junit.Assert.assertTrue
import org.junit.Test
import retrofit2.Response
import java.io.File
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
class VoiceNoteRepositoryTest {
private val api = mockk<HermesApiService>()
@@ -73,6 +75,40 @@ class VoiceNoteRepositoryTest {
coVerify(exactly = 1) { api.transcribeAudio(any()) }
}
@Test
fun transcribe_retriesOnceWhenTheConnectNeverEstablished() =
runTest {
// A connect failure means the request never reached the server, so
// one re-attempt is safe and rescues Wi-Fi/cellular handoffs that
// surface as "Network is unreachable" (device follow-up, #1247).
val file = tempVoiceFile()
var attempts = 0
coEvery { api.transcribeAudio(any()) } coAnswers {
attempts++
if (attempts == 1) {
throw ConnectException("failed to connect to /192.168.1.10:8000")
}
Response.success(AudioTranscriptionResponse(ok = true, transcript = " hi "))
}
val result = VoiceNoteRepository { _ -> api }.transcribe(file)
assertEquals("hi", (result as NetworkResult.Success).data)
coVerify(exactly = 2) { api.transcribeAudio(any()) }
}
@Test
fun transcribe_givesUpAfterOneConnectRetry() =
runTest {
val file = tempVoiceFile()
coEvery { api.transcribeAudio(any()) } throws UnknownHostException("no dns")
val result = VoiceNoteRepository { _ -> api }.transcribe(file)
assertTrue(result is NetworkResult.Failure)
coVerify(exactly = 2) { api.transcribeAudio(any()) }
}
@Test
fun transcribe_resolvesTheServiceAtCallTime_soAConnectionSwitchUsesTheNewService() =
runTest {
@@ -0,0 +1,255 @@
package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.AccountConnectorResult
import com.m57.hermescontrol.data.model.ConnectorTool
import io.mockk.every
import io.mockk.mockkObject
import io.mockk.unmockkAll
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class AccountConnectorRepositoryTest {
@Before fun setup() {
mockkObject(AuthManager)
every { AuthManager.activeProfileId } returns MutableStateFlow("work")
every { AuthManager.currentDataScope() } returns null
}
@After fun teardown() = unmockkAll()
@Test fun `connect decodes operation snapshot and preserves authorization link without a session`() =
runTest {
val calls = mutableListOf<Pair<String, Map<String, Any>>>()
val repo =
HermesAccountConnectorRepository { method, params ->
calls += method to params
operationFixture()
}
val result = repo.connect(listOf("drive"), true) as AccountConnectorResult.Success
assertTrue(result.value.accountOwned)
assertEquals("op-a", result.value.opId)
assertEquals(
"https://example.com/authorize",
result.value.targets
.single()
.safeConnectUrl,
)
assertEquals(
mapOf(
"profile" to "work",
"owner" to mapOf("type" to "account"),
"connectors" to listOf("drive"),
"reconnect" to true,
),
calls.single().second,
)
assertEquals(WsMethods.CONNECTORS_CONNECT, calls.single().first)
}
@Test fun `account operation respond status and wake retain profile and owner`() =
runTest {
val calls = mutableListOf<Pair<String, Map<String, Any>>>()
val repo =
HermesAccountConnectorRepository { method, params ->
calls += method to params
operationFixture()
}
repo.operationStatus("op-a")
repo.operationRequest(
WsMethods.CONNECTION_RESPOND,
mapOf(
"op_id" to "op-a",
"result" to mapOf("settled_by" to "continue"),
),
)
repo.operationRequest(WsMethods.CONNECTORS_OPERATION_WAKE, mapOf("op_id" to "op-a"))
assertEquals(
listOf(
WsMethods.CONNECTORS_OPERATION_STATUS,
WsMethods.CONNECTION_RESPOND,
WsMethods.CONNECTORS_OPERATION_WAKE,
),
calls.map {
it.first
},
)
calls.forEach { (_, params) ->
assertEquals("work", params["profile"])
assertEquals(mapOf("type" to "account"), params["owner"])
assertEquals("op-a", params["op_id"])
assertFalse("session_id" in params)
}
}
@Test fun `catalog accounts tools and removal use native account endpoints`() =
runTest {
val calls = mutableListOf<Pair<String, Map<String, Any>>>()
val repo =
HermesAccountConnectorRepository { method, params ->
calls += method to params
when (method) {
WsMethods.CONNECTORS_CATALOG -> {
mapOf(
"connectors" to
listOf(
mapOf(
"slug" to "drive",
"name" to "Google Drive",
"description" to "Files",
"category" to "storage",
),
),
)
}
WsMethods.CONNECTORS_ACCOUNTS -> {
mapOf(
"accounts" to
listOf(
mapOf(
"connection_id" to "conn-1",
"connector" to "drive",
"label" to "Work account",
"status" to "active",
"active" to true,
"alias" to null,
),
),
)
}
WsMethods.CONNECTORS_TOOLS -> {
mapOf(
"tools" to
listOf(
mapOf(
"slug" to "delete",
"name" to "Delete file",
"hints" to listOf("destructive"),
),
),
)
}
WsMethods.CONNECTORS_ACCOUNTS_REMOVE -> {
mapOf(
"connection_id" to "conn-1",
"connector" to "drive",
"status" to "removed",
)
}
else -> {
error(method)
}
}
}
assertEquals("Google Drive", (repo.catalog() as AccountConnectorResult.Success).value.single().name)
assertEquals("Work account", (repo.accounts() as AccountConnectorResult.Success).value.single().label)
assertEquals(
"Delete file",
(repo.tools("drive", true) as AccountConnectorResult.Success).value.single().name,
)
assertTrue(repo.remove("conn-1") is AccountConnectorResult.Success)
assertTrue(calls.all { "owner" !in it.second && it.second["profile"] == "work" })
assertEquals(mapOf("profile" to "work", "slug" to "drive", "refresh" to true), calls[2].second)
assertEquals(mapOf("profile" to "work", "connection_id" to "conn-1"), calls[3].second)
}
@Test fun `policy keeps member revision separate from effective and respects inherited rules`() =
runTest {
val repo = HermesAccountConnectorRepository { _, _ -> policyFixture() }
val policy = (repo.policy() as AccountConnectorResult.Success).value
assertEquals("effective-revision", policy.revision)
assertEquals("01ARZ3NDEKTSV4RRFFQ69G5FAV", policy.member?.revision)
assertEquals(listOf("mine"), policy.member?.tools?.get("drive"))
val destructive = ConnectorTool("delete", "Delete", "", "destructive", false, listOf("destructive"))
assertFalse(policy.inherited.single().toolEnabled("drive", destructive))
assertFalse(policy.copy(mode = "deny-all").connectorEnabled("drive"))
assertFalse(policy.copy(mode = "future-mode").connectorEnabled("drive"))
}
@Test fun `writes use observed member revision not effective revision or fresh overwritten version`() =
runTest {
val calls = mutableListOf<Pair<String, Map<String, Any>>>()
val repo =
HermesAccountConnectorRepository { method, params ->
calls += method to params
if (method == WsMethods.CONNECTORS_POLICY_GET) {
policyFixture()
} else {
mapOf(
"revision" to "new-member-revision",
"effective" to mapOf("mode" to "deny-all", "revision" to "effective-revision"),
)
}
}
assertTrue(
repo.setDisabledTools(
"drive",
listOf("delete"),
"01ARZ3NDEKTSV4RRFFQ69G5FAV",
) is AccountConnectorResult.Success,
)
assertEquals(WsMethods.CONNECTORS_POLICY_SET, calls.first().first)
assertEquals("01ARZ3NDEKTSV4RRFFQ69G5FAV", calls.first().second["expected_revision"])
assertEquals(
mapOf("type" to "tools", "connector" to "drive", "disabled_tools" to listOf("delete")),
calls.first().second["change"],
)
}
@Test fun `malformed authorization does not look like a successful connect`() =
runTest {
val repo = HermesAccountConnectorRepository { _, _ -> mapOf("status" to "initiated") }
assertTrue(repo.connect(listOf("drive")) is AccountConnectorResult.Failure)
}
private fun operationFixture() =
mapOf(
"op_id" to "op-a",
"seq" to 1,
"deadline_at" to 2000000000.0,
"settled" to false,
"targets" to
listOf(
mapOf(
"name" to "drive",
"kind" to "connector",
"action" to "authorize",
"state" to "initiated",
"connect_url" to "https://example.com/authorize",
),
),
)
private fun policyFixture() =
mapOf(
"effective" to
mapOf(
"mode" to "deny",
"revision" to "effective-revision",
"disabled_connectors" to emptyList<String>(),
),
"layers" to
listOf(
mapOf(
"kind" to "member",
"revision" to "01ARZ3NDEKTSV4RRFFQ69G5FAV",
"body" to mapOf("mode" to "deny", "tools" to mapOf("drive" to listOf("mine"))),
),
mapOf(
"kind" to "org",
"revision" to "org-revision",
"body" to mapOf("mode" to "deny", "tags" to mapOf("disable" to listOf("destructive"))),
),
),
)
}
@@ -1,6 +1,7 @@
package com.m57.hermescontrol.data.ws
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -63,6 +64,39 @@ class ConnectionOperationEventParserTest {
assertEquals("", (event as WsEvent.Unknown).raw)
}
@Test
fun connectionUpdate_bindsToSessionOwner() {
val event =
EventParser.parseParams(
mapOf(
"type" to "connection.update",
"payload" to
operationPayload(seq = 2L) +
mapOf("owner" to mapOf("type" to "session", "session_id" to "owner-session")),
),
)
assertTrue(event is WsEvent.ConnectionUpdate)
assertEquals("owner-session", (event as WsEvent.ConnectionUpdate).snapshot.sessionId)
}
@Test
fun connectionUpdate_accountOwnerNeverBindsToSession() {
val event =
EventParser.parseParams(
mapOf(
"type" to "connection.update",
"session_id" to "",
"payload" to
operationPayload(seq = 2L) +
mapOf("owner" to mapOf("type" to "account"), "session_id" to "stale-session"),
),
)
assertTrue(event is WsEvent.ConnectionUpdate)
assertNull((event as WsEvent.ConnectionUpdate).snapshot.sessionId)
}
private fun operationPayload(seq: Long): Map<String, Any?> =
mapOf(
"op_id" to "op-1",
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.model.CatalogScanStatus
import com.m57.hermescontrol.data.model.ConnectionTargetAction
import com.m57.hermescontrol.data.model.ConnectionTargetKind
import com.m57.hermescontrol.data.model.ConnectionTargetState
@@ -60,6 +61,73 @@ class ConnectionOperationParserTest {
assertEquals(ConnectionTargetState.UNKNOWN, target.state)
}
@Test
fun pluginAndSkillTargets_parseKindAndCatalogFacts() {
val payload =
completePayload() +
(
"targets" to
listOf(
mapOf(
"name" to "linear",
"kind" to "plugin",
"action" to "install",
"state" to "pending",
"display" to " Linear ",
"description" to "Linear issues.",
"tier" to "official",
"repo" to "NousResearch/hermes-plugins",
"sha" to "0123456789abcdef0123456789abcdef01234567",
"subdir" to "linear",
"scan" to mapOf("status" to "warnings", "summary" to "1 advisory"),
"requirements" to listOf("Hermes >=0.21", "LINEAR_API_KEY environment variable"),
"platforms" to listOf("linux", ""),
"target_profile" to "work",
"required_env" to
listOf(mapOf("name" to "LINEAR_API_KEY", "required" to false, "secret" to true)),
),
mapOf(
"name" to "owner/skills/review",
"kind" to "skill",
"action" to "install",
"state" to "connected",
"skill" to "review",
"scan" to mapOf("status" to "future-status"),
),
)
)
val (plugin, skill) = ConnectionOperationParser.parse(payload, "runtime-session")!!.targets
assertEquals(ConnectionTargetKind.PLUGIN, plugin.kind)
val catalog = plugin.catalog!!
assertEquals("Linear", catalog.display)
assertEquals("official", catalog.tier)
assertEquals("NousResearch/hermes-plugins", catalog.repo)
assertEquals("0123456789abcdef0123456789abcdef01234567", catalog.sha)
assertEquals("linear", catalog.subdir)
assertEquals(CatalogScanStatus.WARNINGS, catalog.scan?.status)
assertEquals("1 advisory", catalog.scan?.summary)
assertEquals(2, catalog.requirements.size)
assertEquals(listOf("linux"), catalog.platforms)
assertEquals("work", catalog.targetProfile)
assertEquals(listOf("LINEAR_API_KEY"), plugin.requiredEnv.map { it.name })
assertEquals(ConnectionTargetKind.SKILL, skill.kind)
assertEquals("owner/skills/review", skill.catalog?.display)
assertEquals("default", skill.catalog?.targetProfile)
assertEquals("review", skill.catalog?.skill)
assertEquals(CatalogScanStatus.UNKNOWN, skill.catalog?.scan?.status)
assertNull(skill.catalog?.repo)
}
@Test
fun nonCatalogTargets_carryNoCatalogFacts() {
val targets = ConnectionOperationParser.parse(completePayload(), "runtime-session")!!.targets
assertTrue(targets.all { it.catalog == null })
}
@Test
fun malformedIdentityOrDeadline_isRejected() {
assertNull(ConnectionOperationParser.parse(completePayload() - "op_id", "runtime-session"))
@@ -44,7 +44,10 @@ class ConnectorRepositoryTest {
val result = repo.listConnectors("sess-abc-123")
assertTrue(result is ConnectorListResult.Success)
assertEquals(WsMethods.CONNECTORS_LIST, recordedMethod)
assertEquals(mapOf("session_id" to "sess-abc-123"), recordedParams)
assertEquals(
mapOf("owner" to mapOf("type" to "session", "session_id" to "sess-abc-123")),
recordedParams,
)
// Verify no profile or singular slug mutations
assertFalse(recordedParams?.containsKey("profile") == true)
assertFalse(recordedParams?.containsKey("connector") == true)
@@ -80,9 +83,14 @@ class ConnectorRepositoryTest {
assertTrue(result is ConnectorConnectResult.Success)
assertEquals(WsMethods.CONNECTORS_CONNECT, recordedMethod)
assertEquals("sess-456", recordedParams?.get("session_id"))
assertEquals(listOf("linear", "github_app-1"), recordedParams?.get("connectors"))
assertEquals(true, recordedParams?.get("reconnect"))
assertEquals(
mapOf(
"owner" to mapOf("type" to "session", "session_id" to "sess-456"),
"connectors" to listOf("linear", "github_app-1"),
"reconnect" to true,
),
recordedParams,
)
// Verify no singular slug key mutation
assertFalse(recordedParams?.containsKey("connector") == true)
@@ -0,0 +1,376 @@
package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.serialization.SerializationException
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.encodeToJsonElement
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.Assert.fail
import org.junit.Test
class PluginManageModelsTest {
// CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types.
@Test
fun `PluginSettingField decodes and encodes string type correctly`() {
val jsonString =
"""
{
"key": "api_host",
"type": "string",
"label": "API Host",
"description": "Hostname for API",
"required": true,
"value": "https://api.example.com",
"default": "https://localhost",
"choices": null,
"env": "API_HOST",
"has_value": true
}
""".trimIndent()
val field = OkHttpProvider.json.decodeFromString<PluginSettingField>(jsonString)
assertEquals("api_host", field.key)
assertEquals(PluginSettingFieldType.STRING, field.type)
assertEquals("API Host", field.label)
assertEquals("Hostname for API", field.description)
assertTrue(field.required)
assertEquals(JsonPrimitive("https://api.example.com"), field.value)
assertEquals(JsonPrimitive("https://localhost"), field.default)
assertNull(field.choices)
assertEquals("API_HOST", field.env)
assertEquals(true, field.hasValue)
val encoded = OkHttpProvider.json.encodeToString(field)
val roundTripped = OkHttpProvider.json.decodeFromString<PluginSettingField>(encoded)
assertEquals(field, roundTripped)
}
// CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types.
@Test
fun `PluginSettingField decodes and encodes number type with numeric value and default`() {
val jsonString =
"""
{
"key": "port",
"type": "number",
"label": "Port Number",
"value": 8080,
"default": 3000
}
""".trimIndent()
val field = OkHttpProvider.json.decodeFromString<PluginSettingField>(jsonString)
assertEquals("port", field.key)
assertEquals(PluginSettingFieldType.NUMBER, field.type)
assertEquals(JsonPrimitive(8080), field.value)
assertEquals(JsonPrimitive(3000), field.default)
assertEquals("", field.description)
assertFalse(field.required)
assertNull(field.choices)
assertNull(field.env)
assertNull(field.hasValue)
}
// CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types.
@Test
fun `PluginSettingField decodes and encodes boolean type with default false`() {
val jsonString =
"""
{
"key": "enabled",
"type": "boolean",
"label": "Enabled",
"value": true,
"default": false
}
""".trimIndent()
val field = OkHttpProvider.json.decodeFromString<PluginSettingField>(jsonString)
assertEquals("enabled", field.key)
assertEquals(PluginSettingFieldType.BOOLEAN, field.type)
assertEquals(JsonPrimitive(true), field.value)
assertEquals(JsonPrimitive(false), field.default)
}
// CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types.
@Test
fun `PluginSettingField decodes enum type with choices`() {
val jsonString =
"""
{
"key": "mode",
"type": "enum",
"label": "Execution Mode",
"choices": ["fast", "balanced", "thorough"],
"value": "fast",
"default": "balanced"
}
""".trimIndent()
val field = OkHttpProvider.json.decodeFromString<PluginSettingField>(jsonString)
assertEquals("mode", field.key)
assertEquals(PluginSettingFieldType.ENUM, field.type)
assertEquals(listOf("fast", "balanced", "thorough"), field.choices)
assertEquals(JsonPrimitive("fast"), field.value)
assertEquals(JsonPrimitive("balanced"), field.default)
}
// CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types.
@Test
fun `PluginSettingField decodes secret type with masked or null value and has_value flag`() {
val jsonString =
"""
{
"key": "api_token",
"type": "secret",
"label": "API Token",
"value": null,
"env": "SECRET_TOKEN",
"has_value": true
}
""".trimIndent()
val field = OkHttpProvider.json.decodeFromString<PluginSettingField>(jsonString)
assertEquals("api_token", field.key)
assertEquals(PluginSettingFieldType.SECRET, field.type)
assertEquals(null, field.value)
assertEquals("SECRET_TOKEN", field.env)
assertEquals(true, field.hasValue)
}
// CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types.
@Test
fun `PluginSettingField decodes json type with object or array values`() {
val jsonString =
"""
{
"key": "extra_config",
"type": "json",
"label": "Extra Config",
"value": {"timeout": 30, "retries": 3},
"default": {}
}
""".trimIndent()
val field = OkHttpProvider.json.decodeFromString<PluginSettingField>(jsonString)
assertEquals("extra_config", field.key)
assertEquals(PluginSettingFieldType.JSON, field.type)
assertTrue(field.value is JsonObject)
val valueObj = field.value as JsonObject
assertEquals(JsonPrimitive(30), valueObj["timeout"])
assertEquals(JsonPrimitive(3), valueObj["retries"])
assertTrue(field.default is JsonObject)
}
// CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types (error case).
@Test
fun `PluginSettingField decoding fails on unknown type value`() {
val jsonString =
"""
{
"key": "invalid_field",
"type": "unknown_future_type",
"label": "Invalid"
}
""".trimIndent()
try {
OkHttpProvider.json.decodeFromString<PluginSettingField>(jsonString)
fail("Expected SerializationException for unknown type")
} catch (_: SerializationException) {
// expected
}
}
// CONTRACT: PluginServerState (CONNECTED, APP_NOT_RUNNING, ENDPOINT_UNAVAILABLE, VERSION_TOO_OLD, MISSING_APP, UNKNOWN).
@Test
fun `PluginServerState decodes all enum variants from snake_case strings`() {
val expectedStates =
mapOf(
"connected" to PluginServerState.CONNECTED,
"app_not_running" to PluginServerState.APP_NOT_RUNNING,
"endpoint_unavailable" to PluginServerState.ENDPOINT_UNAVAILABLE,
"no_interactive_session" to PluginServerState.NO_INTERACTIVE_SESSION,
"version_too_old" to PluginServerState.VERSION_TOO_OLD,
"missing_app" to PluginServerState.MISSING_APP,
"unknown" to PluginServerState.UNKNOWN,
)
for ((serialized, expectedEnum) in expectedStates) {
val decoded = OkHttpProvider.json.decodeFromString<PluginServerState>("\"$serialized\"")
assertEquals("Mismatch for $serialized", expectedEnum, decoded)
val reEncoded = OkHttpProvider.json.encodeToString(expectedEnum)
assertEquals("\"$serialized\"", reEncoded)
}
}
// CONTRACT: PluginServerState (CONNECTED, APP_NOT_RUNNING, ENDPOINT_UNAVAILABLE, VERSION_TOO_OLD, MISSING_APP, UNKNOWN) via PluginServerRow.
@Test
fun `PluginServerRow decodes name state and sentence correctly`() {
val jsonString =
"""
{
"name": "camofox_service",
"state": "app_not_running",
"sentence": "Camofox browser is not running on port 9333."
}
""".trimIndent()
val row = OkHttpProvider.json.decodeFromString<PluginServerRow>(jsonString)
assertEquals("camofox_service", row.name)
assertEquals(PluginServerState.APP_NOT_RUNNING, row.state)
assertEquals("Camofox browser is not running on port 9333.", row.sentence)
}
// CONTRACT: AgentPluginRow decoding from backend plugins.manage list payload shape.
@Test
fun `AgentPluginRow decodes full payload shape with servers and settings schema`() {
val jsonString =
"""
{
"name": "camofox",
"key": "camofox-browser",
"version": "1.2.0",
"description": "Anti-detect browser automation",
"source": "installed",
"status": "active",
"portable": true,
"install_dir": "/home/user/.hermes/plugins/camofox",
"has_desktop_half": true,
"servers": [
{
"name": "cdp",
"state": "connected",
"sentence": "CDP connected"
}
],
"settings_schema": [
{
"key": "port",
"type": "number",
"label": "CDP Port",
"value": 9222
}
],
"catalog_name": "camofox",
"catalog_tier": "community",
"installed_sha": "abc1234",
"catalog_sha": "def5678",
"catalog_version": "1.3.0",
"update_available": true,
"pinned_sha": "abc1234"
}
""".trimIndent()
val row = OkHttpProvider.json.decodeFromString<AgentPluginRow>(jsonString)
assertEquals("camofox", row.name)
assertEquals("camofox-browser", row.key)
assertEquals("1.2.0", row.version)
assertEquals("Anti-detect browser automation", row.description)
assertEquals("installed", row.source)
assertEquals("status" to "active", "status" to row.status)
assertTrue(row.portable)
assertEquals("/home/user/.hermes/plugins/camofox", row.installDir)
assertTrue(row.hasDesktopHalf)
assertEquals(1, row.servers.size)
assertEquals("cdp", row.servers[0].name)
assertEquals(PluginServerState.CONNECTED, row.servers[0].state)
assertEquals(1, row.settingsSchema?.size)
assertEquals("port", row.settingsSchema?.get(0)?.key)
assertEquals(PluginSettingFieldType.NUMBER, row.settingsSchema?.get(0)?.type)
assertEquals("camofox", row.catalogName)
assertEquals("community", row.catalogTier)
assertEquals("abc1234", row.installedSha)
assertEquals("def5678", row.catalogSha)
assertEquals("1.3.0", row.catalogVersion)
assertEquals(true, row.updateAvailable)
assertEquals("abc1234", row.pinnedSha)
}
// CONTRACT: AgentPluginRow decoding from backend plugins.manage list payload shape (defaults/nulls).
@Test
fun `AgentPluginRow decodes minimal required payload using defaults`() {
val jsonString =
"""
{
"name": "minimal_plugin"
}
""".trimIndent()
val row = OkHttpProvider.json.decodeFromString<AgentPluginRow>(jsonString)
assertEquals("minimal_plugin", row.name)
assertNull(row.key)
assertEquals("", row.version)
assertEquals("", row.description)
assertEquals("", row.source)
assertEquals("", row.status)
assertFalse(row.portable)
assertEquals("", row.installDir)
assertFalse(row.hasDesktopHalf)
assertTrue(row.servers.isEmpty())
assertNull(row.settingsSchema)
assertNull(row.catalogName)
assertNull(row.catalogTier)
assertNull(row.installedSha)
assertNull(row.catalogSha)
assertNull(row.catalogVersion)
assertNull(row.updateAvailable)
assertNull(row.pinnedSha)
}
// CONTRACT: PluginsManageSettingsResult decoding happy path and error cases.
@Test
fun `PluginsManageSettingsResult decodes success with updated plugin and written keys`() {
val jsonString =
"""
{
"ok": true,
"name": "camofox",
"written": ["port", "headless"],
"plugin": {
"name": "camofox",
"version": "1.2.0"
},
"error": null
}
""".trimIndent()
val result = OkHttpProvider.json.decodeFromString<PluginsManageSettingsResult>(jsonString)
assertTrue(result.ok)
assertEquals("camofox", result.name)
assertEquals(listOf("port", "headless"), result.written)
assertNotNull(result.plugin)
assertEquals("camofox", result.plugin?.name)
assertNull(result.error)
}
// CONTRACT: PluginsManageSettingsResult decoding happy path and error cases.
@Test
fun `PluginsManageSettingsResult decodes failure result with error message`() {
val jsonString =
"""
{
"ok": false,
"name": "camofox",
"error": "Validation failed: port must be an integer"
}
""".trimIndent()
val result = OkHttpProvider.json.decodeFromString<PluginsManageSettingsResult>(jsonString)
assertFalse(result.ok)
assertEquals("camofox", result.name)
assertEquals("Validation failed: port must be an integer", result.error)
assertNull(result.written)
assertNull(result.plugin)
}
}
@@ -0,0 +1,236 @@
package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.encodeToJsonElement
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.Assert.fail
import org.junit.Test
class PluginManageRepositoryTest {
// CONTRACT: `listPlugins(profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "list"} (and profile if set) and decodes plugins list.
@Test
fun `listPlugins without profile calls PLUGINS_MANAGE action list and decodes plugins list`() =
runTest {
var capturedMethod = ""
var capturedParams: Map<String, Any?> = emptyMap()
val mockPayload =
OkHttpProvider.json.parseToJsonElement(
"""
{
"plugins": [
{
"name": "plugin-alpha",
"version": "1.0.0",
"status": "active"
},
{
"name": "plugin-beta",
"version": "2.1.0",
"status": "disabled"
}
]
}
""".trimIndent(),
)
val repository =
PluginManageRepository { method, params ->
capturedMethod = method
capturedParams = params
mockPayload
}
val plugins = repository.listPlugins()
assertEquals(WsMethods.PLUGINS_MANAGE, capturedMethod)
assertEquals(mapOf("action" to "list"), capturedParams)
assertEquals(2, plugins.size)
assertEquals("plugin-alpha", plugins[0].name)
assertEquals("1.0.0", plugins[0].version)
assertEquals("active", plugins[0].status)
assertEquals("plugin-beta", plugins[1].name)
assertEquals("2.1.0", plugins[1].version)
assertEquals("disabled", plugins[1].status)
}
// CONTRACT: `listPlugins(profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "list"} (and profile if set) and decodes plugins list.
@Test
fun `listPlugins with non-blank profile includes profile parameter in request`() =
runTest {
var capturedMethod = ""
var capturedParams: Map<String, Any?> = emptyMap()
val mockPayload =
OkHttpProvider.json.parseToJsonElement(
"""
{
"plugins": []
}
""".trimIndent(),
)
val repository =
PluginManageRepository { method, params ->
capturedMethod = method
capturedParams = params
mockPayload
}
val plugins = repository.listPlugins(profile = "work")
assertEquals(WsMethods.PLUGINS_MANAGE, capturedMethod)
assertEquals(mapOf("action" to "list", "profile" to "work"), capturedParams)
assertTrue(plugins.isEmpty())
}
// CONTRACT: `listPlugins(profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "list"} (and profile if set) and decodes plugins list.
@Test
fun `listPlugins returns empty list when plugins key is missing or response is not a JsonObject`() =
runTest {
val emptyObjRepo =
PluginManageRepository { _, _ ->
OkHttpProvider.json.parseToJsonElement("{}")
}
assertTrue(emptyObjRepo.listPlugins().isEmpty())
val nonObjRepo =
PluginManageRepository { _, _ ->
OkHttpProvider.json.parseToJsonElement("[]")
}
assertTrue(nonObjRepo.listPlugins().isEmpty())
val nullRepo =
PluginManageRepository { _, _ ->
null
}
assertTrue(nullRepo.listPlugins().isEmpty())
}
// CONTRACT: `saveSettings(key, values, profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "settings", "key": key, "values": values} and decodes result.
@Test
fun `saveSettings without profile calls PLUGINS_MANAGE with action settings key and JsonObject values`() =
runTest {
var capturedMethod = ""
var capturedParams: Map<String, Any?> = emptyMap()
val mockResponse =
OkHttpProvider.json.parseToJsonElement(
"""
{
"ok": true,
"name": "camofox",
"written": ["port", "headless"],
"plugin": {
"name": "camofox",
"version": "1.0.0"
}
}
""".trimIndent(),
)
val repository =
PluginManageRepository { method, params ->
capturedMethod = method
capturedParams = params
mockResponse
}
val valuesToSave: Map<String, JsonElement> =
mapOf(
"port" to JsonPrimitive(9222),
"headless" to JsonPrimitive(true),
)
val result = repository.saveSettings(key = "camofox", values = valuesToSave)
assertEquals(WsMethods.PLUGINS_MANAGE, capturedMethod)
assertEquals("settings", capturedParams["action"])
assertEquals("camofox", capturedParams["key"])
assertEquals(JsonObject(valuesToSave), capturedParams["values"])
assertFalse(capturedParams.containsKey("profile"))
assertTrue(result.ok)
assertEquals("camofox", result.name)
assertEquals(listOf("port", "headless"), result.written)
assertNotNull(result.plugin)
assertEquals("camofox", result.plugin?.name)
assertNull(result.error)
}
// CONTRACT: `saveSettings(key, values, profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "settings", "key": key, "values": values} and decodes result.
@Test
fun `saveSettings with profile includes profile in request and handles error result`() =
runTest {
var capturedMethod = ""
var capturedParams: Map<String, Any?> = emptyMap()
val mockResponse =
OkHttpProvider.json.parseToJsonElement(
"""
{
"ok": false,
"name": "camofox",
"error": "Port already in use"
}
""".trimIndent(),
)
val repository =
PluginManageRepository { method, params ->
capturedMethod = method
capturedParams = params
mockResponse
}
val valuesToSave: Map<String, JsonElement> =
mapOf(
"port" to JsonPrimitive(8080),
)
val result = repository.saveSettings(key = "camofox", values = valuesToSave, profile = "staging")
assertEquals(WsMethods.PLUGINS_MANAGE, capturedMethod)
assertEquals(
mapOf(
"action" to "settings",
"key" to "camofox",
"values" to JsonObject(valuesToSave),
"profile" to "staging",
),
capturedParams,
)
assertFalse(result.ok)
assertEquals("camofox", result.name)
assertEquals("Port already in use", result.error)
assertNull(result.written)
assertNull(result.plugin)
}
// CONTRACT: `saveSettings(key, values, profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "settings", "key": key, "values": values} and decodes result (error case).
@Test
fun `saveSettings throws SerializationException when response is malformed`() =
runTest {
val repository =
PluginManageRepository { _, _ ->
OkHttpProvider.json.parseToJsonElement("{\"ok\": \"not-a-boolean\"}")
}
try {
repository.saveSettings("camofox", emptyMap())
fail("Expected SerializationException when response JSON is malformed")
} catch (_: SerializationException) {
// Expected
}
}
}
@@ -289,7 +289,7 @@ class WsProfileParamsTest {
* home) without being listed here, this test fails — the set cannot drift.
*
* Methods that operate on an ALREADY-BOUND session (session.usage,
* session.interrupt, session.branch, session.redirect,
* session.interrupt, session.branch, session.branch_whole, session.redirect,
* session.context_breakdown, prompt.submit, slash/command dispatch,
* approval/clarify/secret/sudo responses, file attach, process.*,
* subscription.*, commands_catalog, usage.bars) resolve via the
@@ -0,0 +1,533 @@
package com.m57.hermescontrol.ui.chat
import android.app.Application
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.DataScope
import com.m57.hermescontrol.data.local.HermesDatabase
import com.m57.hermescontrol.data.model.CompressionSummary
import com.m57.hermescontrol.data.model.SessionCompressResponse
import com.m57.hermescontrol.data.model.SessionMessage
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository
import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ChatCompressRpcTest {
private val testDispatcher = StandardTestDispatcher()
private val mockEventsFlow = MutableSharedFlow<WsEvent>(extraBufferCapacity = 64)
private val mockConnectionStatus = MutableStateFlow(ConnectionStatus.DISCONNECTED)
private lateinit var app: Application
private lateinit var fakeRepo: FakeChatPersistenceRepository
private var reqCount = 0
private var sessionCreateRequestId: String? = null
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
reqCount = 0
sessionCreateRequestId = null
mockkStatic(android.util.Log::class)
every { android.util.Log.d(any(), any()) } returns 0
every { android.util.Log.i(any(), any()) } returns 0
every { android.util.Log.w(any(), any<String>()) } returns 0
every { android.util.Log.e(any(), any(), any()) } returns 0
mockkObject(AuthManager)
every { AuthManager.getPinnedModels() } returns emptyList()
every { AuthManager.dataScopeFlow } returns MutableStateFlow<DataScope?>(null)
every { AuthManager.activeProfileId } returns MutableStateFlow<String?>(null)
every { AuthManager.currentDataScope() } returns null
every { AuthManager.getToken() } returns "test-token"
every { AuthManager.getBaseUrl() } returns "http://test.local/"
every { AuthManager.getSelectedProfileId() } returns null
every { AuthManager.getBusySendMode() } returns com.m57.hermescontrol.data.model.BusySendMode.CORRECT
every { AuthManager.isTypingEffectEnabled() } returns true
every { AuthManager.getTypingEffectDelayMs() } returns 30
every { AuthManager.isMessageStatsEnabled() } returns false
every { AuthManager.isUserMessageTokensEnabled() } returns true
every { AuthManager.isAssistantMessageTokensEnabled() } returns true
every { AuthManager.isTokensPerSecondEnabled() } returns true
every { AuthManager.isModelProviderShown() } returns false
every { AuthManager.isAutoReconnect() } returns false
every { AuthManager.isRestoreLastSession() } returns false
every { AuthManager.getLastOpenedSessionId() } returns null
every { AuthManager.setLastOpenedSessionId(any()) } returns Unit
every { AuthManager.clearLastOpenedSessionId() } returns Unit
mockkObject(HermesWsClient)
every { HermesWsClient.events } returns mockEventsFlow
every { HermesWsClient.connectionStatus } returns mockConnectionStatus
every { HermesWsClient.connect() } answers {
mockConnectionStatus.value = ConnectionStatus.CONNECTING
}
every { HermesWsClient.disconnect() } returns Unit
every { HermesWsClient.send(any(), any(), any()) } answers {
reqCount++
val id = "req-id-$reqCount"
if (arg<String>(0) == WsMethods.SESSION_CREATE) {
sessionCreateRequestId = id
}
arg<((String) -> Unit)?>(2)?.invoke(id)
id
}
every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers {
reqCount++
val id = "req-msg-$reqCount"
arg<((String) -> Unit)?>(2)?.invoke(id)
id
}
every { HermesWsClient.request(any(), any(), any()) } answers {
HermesWsClient.send(arg(0), arg(1)) {}
CompletableDeferred<Any?>(Unit)
}
mockkObject(ApiClient)
coEvery { ApiClient.hermesApi.getModelInfo() } returns retrofit2.Response.success(mockk(relaxed = true))
coEvery { ApiClient.hermesApi.getProfiles() } returns
retrofit2.Response.success(
com.m57.hermescontrol.data.model
.ProfilesResponse(emptyList()),
)
mockkObject(HermesDatabase)
mockkObject(ProfileSwitchCoordinator)
every { ProfileSwitchCoordinator.switched } returns MutableSharedFlow<String>()
every { ProfileSwitchCoordinator.connectionSwitched } returns MutableSharedFlow<String>()
app = mockk(relaxed = true)
fakeRepo = FakeChatPersistenceRepository()
mockConnectionStatus.value = ConnectionStatus.DISCONNECTED
}
@After
fun tearDown() {
Dispatchers.resetMain()
unmockkAll()
}
private suspend fun TestScope.createViewModelWithSession(): Pair<ChatViewModel, String> {
val vm =
ChatViewModel(
app,
startCleanup = false,
repo = fakeRepo,
slashUsageStore = FakeSlashUsageStore(),
searchDispatcher = testDispatcher,
ioDispatcher = testDispatcher,
historyDispatcher = testDispatcher,
sendStore = ChatSendStore(),
)
advanceUntilIdle()
mockConnectionStatus.value = ConnectionStatus.CONNECTED
mockEventsFlow.emit(WsEvent.GatewayReady(null))
advanceUntilIdle()
val createRequestId = requireNotNull(sessionCreateRequestId) { "session.create was not sent" }
mockEventsFlow.emit(WsEvent.RpcResult(createRequestId, mapOf("session_id" to "session-xyz")))
advanceUntilIdle()
return Pair(vm, "session-xyz")
}
@Test
fun `compress calls SESSION_COMPRESS not command dispatch or slash exec`() =
runTest {
val (vm, _) = createViewModelWithSession()
val methodCalls = mutableListOf<String>()
every {
HermesWsClient.request(capture(methodCalls), any(), any())
} answers {
CompletableDeferred<Any?>(null)
}
vm.sendMessage("/compress")
advanceUntilIdle()
assertTrue("expected SESSION_COMPRESS in $methodCalls", WsMethods.SESSION_COMPRESS in methodCalls)
assertTrue("expected NO command.dispatch", WsMethods.COMMAND_DISPATCH !in methodCalls)
assertTrue("expected NO slash.exec", "slash.exec" !in methodCalls)
}
@Test
fun `compress request parameters contain session_id and omit focus_topic when blank`() =
runTest {
val (vm, sessionId) = createViewModelWithSession()
val methodCalls = mutableListOf<String>()
val paramsCalls = mutableListOf<Map<String, Any>>()
every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
} answers {
CompletableDeferred<Any?>(null)
}
vm.sendMessage("/compress")
advanceUntilIdle()
val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS)
assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0)
val params = paramsCalls[compressIndex]
assertEquals(sessionId, params["session_id"])
assertFalse("focus_topic should be omitted when blank", params.containsKey("focus_topic"))
}
@Test
fun `compress request parameters forward focus_topic when non-blank`() =
runTest {
val (vm, sessionId) = createViewModelWithSession()
val methodCalls = mutableListOf<String>()
val paramsCalls = mutableListOf<Map<String, Any>>()
every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
} answers {
CompletableDeferred<Any?>(null)
}
vm.sendMessage("/compress auth decisions")
advanceUntilIdle()
val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS)
assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0)
val params = paramsCalls[compressIndex]
assertEquals(sessionId, params["session_id"])
assertEquals("auth decisions", params["focus_topic"])
}
@Test
fun `compact behaves identically to compress omitting focus_topic when blank`() =
runTest {
val (vm, sessionId) = createViewModelWithSession()
val methodCalls = mutableListOf<String>()
val paramsCalls = mutableListOf<Map<String, Any>>()
every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
} answers {
CompletableDeferred<Any?>(null)
}
vm.sendMessage("/compact")
advanceUntilIdle()
val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS)
assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0)
val params = paramsCalls[compressIndex]
assertEquals(sessionId, params["session_id"])
assertFalse("focus_topic should be omitted when blank", params.containsKey("focus_topic"))
assertTrue(WsMethods.COMMAND_DISPATCH !in methodCalls)
}
@Test
fun `compact forwards focus_topic when non-blank`() =
runTest {
val (vm, sessionId) = createViewModelWithSession()
val methodCalls = mutableListOf<String>()
val paramsCalls = mutableListOf<Map<String, Any>>()
every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
} answers {
CompletableDeferred<Any?>(null)
}
vm.sendMessage("/compact architecture details")
advanceUntilIdle()
val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS)
assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0)
val params = paramsCalls[compressIndex]
assertEquals(sessionId, params["session_id"])
assertEquals("architecture details", params["focus_topic"])
}
@Test
fun `final response summary headline token_line note added to messages`() =
runTest {
val (vm, _) = createViewModelWithSession()
val responseJson =
buildJsonObject {
put("status", JsonPrimitive("ok"))
put(
"summary",
buildJsonObject {
put("headline", JsonPrimitive("Context compressed"))
put("token_line", JsonPrimitive("Tokens: 120k -> 35k (70% saved)"))
put("note", JsonPrimitive("Trimmed 14 earlier turns"))
},
)
}
every {
HermesWsClient.request(WsMethods.SESSION_COMPRESS, any(), any())
} answers {
CompletableDeferred<Any?>(responseJson)
}
vm.sendMessage("/compress")
advanceUntilIdle()
val lastAssistantMessage =
vm.uiState.value.messages
.lastOrNull { it.role == MessageRole.ASSISTANT }
val expectedContent = "Context compressed\nTokens: 120k -> 35k (70% saved)\nTrimmed 14 earlier turns"
assertEquals(expectedContent, lastAssistantMessage?.content)
}
@Test
fun `returned messages replace and reconcile visible live transcript`() =
runTest {
val (vm, _) = createViewModelWithSession()
val serverMessages =
listOf(
SessionMessage(
id = 101,
role = "user",
content = JsonPrimitive("Original prompt"),
),
SessionMessage(
id = 102,
role = "assistant",
content = JsonPrimitive("Compressed response"),
),
)
val responseJson =
buildJsonObject {
put("status", JsonPrimitive("ok"))
put(
"summary",
buildJsonObject {
put("headline", JsonPrimitive("Summary headline"))
},
)
put(
"messages",
buildJsonArray {
add(
buildJsonObject {
put("id", JsonPrimitive(101))
put("role", JsonPrimitive("user"))
put("content", JsonPrimitive("Original prompt"))
},
)
add(
buildJsonObject {
put("id", JsonPrimitive(102))
put("role", JsonPrimitive("assistant"))
put("content", JsonPrimitive("Compressed response"))
},
)
},
)
}
every {
HermesWsClient.request(WsMethods.SESSION_COMPRESS, any(), any())
} answers {
CompletableDeferred<Any?>(responseJson)
}
vm.sendMessage("/compress")
advanceUntilIdle()
val messages = vm.uiState.value.messages
// The transcript contains serverMessages, plus the assistant summary feedback
val userMsg = messages.firstOrNull { it.role == MessageRole.USER && it.content == "Original prompt" }
assertEquals("Original prompt", userMsg?.content)
val serverAssistantMsg =
messages.firstOrNull {
it.role == MessageRole.ASSISTANT && it.content == "Compressed response"
}
assertTrue("expected replacement assistant message", serverAssistantMsg != null)
val summaryMsg = messages.lastOrNull { it.role == MessageRole.ASSISTANT }
assertEquals("Summary headline", summaryMsg?.content)
}
@Test
fun `fetchContextUsage is triggered after successful compression`() =
runTest {
val (vm, sessionId) = createViewModelWithSession()
val methodCalls = mutableListOf<String>()
every {
HermesWsClient.request(capture(methodCalls), any(), any())
} answers {
val method = arg<String>(0)
if (method == WsMethods.SESSION_COMPRESS) {
val responseJson =
buildJsonObject {
put("status", JsonPrimitive("ok"))
put("message", JsonPrimitive("Context compressed."))
}
CompletableDeferred<Any?>(responseJson)
} else {
CompletableDeferred<Any?>(Unit)
}
}
methodCalls.clear()
vm.sendMessage("/compress")
advanceUntilIdle()
// fetchContextUsage calls SESSION_CONTEXT_BREAKDOWN and SESSION_USAGE with sessionId
assertTrue(
"expected SESSION_CONTEXT_BREAKDOWN in $methodCalls",
WsMethods.SESSION_CONTEXT_BREAKDOWN in methodCalls,
)
assertTrue(
"expected SESSION_USAGE in $methodCalls",
WsMethods.SESSION_USAGE in methodCalls,
)
}
@Test
fun `isCompressing is reset to false on success`() =
runTest {
val (vm, _) = createViewModelWithSession()
val deferred = CompletableDeferred<Any?>()
every {
HermesWsClient.request(WsMethods.SESSION_COMPRESS, any(), any())
} returns deferred
vm.sendMessage("/compress")
// Progress before completion
testDispatcher.scheduler.runCurrent()
assertTrue("isCompressing should be true while in flight", vm.uiState.value.isCompressing)
assertEquals("⏳ Compressing context...", vm.uiState.value.compressionStatus)
deferred.complete(
buildJsonObject {
put("status", JsonPrimitive("ok"))
},
)
advanceUntilIdle()
assertFalse("isCompressing should be reset to false", vm.uiState.value.isCompressing)
assertEquals(null, vm.uiState.value.compressionStatus)
}
@Test
fun `isCompressing is reset to false on RPC error`() =
runTest {
val (vm, _) = createViewModelWithSession()
val deferred = CompletableDeferred<Any?>()
every {
HermesWsClient.request(WsMethods.SESSION_COMPRESS, any(), any())
} returns deferred
vm.sendMessage("/compress")
testDispatcher.scheduler.runCurrent()
assertTrue(vm.uiState.value.isCompressing)
deferred.completeExceptionally(
HermesWsClient.HermesRpcException("compression failed on backend", 4018),
)
advanceUntilIdle()
assertFalse("isCompressing should be false on RPC error", vm.uiState.value.isCompressing)
assertEquals(null, vm.uiState.value.compressionStatus)
val errorMsg =
vm.uiState.value.messages
.lastOrNull { it.role == MessageRole.ASSISTANT }
assertTrue(errorMsg?.content?.contains("compression failed on backend") == true)
}
@Test
fun `isCompressing is reset to false on generic exception`() =
runTest {
val (vm, _) = createViewModelWithSession()
val deferred = CompletableDeferred<Any?>()
every {
HermesWsClient.request(WsMethods.SESSION_COMPRESS, any(), any())
} returns deferred
vm.sendMessage("/compress")
testDispatcher.scheduler.runCurrent()
assertTrue(vm.uiState.value.isCompressing)
deferred.completeExceptionally(RuntimeException("network disconnected"))
advanceUntilIdle()
assertFalse("isCompressing should be false on generic exception", vm.uiState.value.isCompressing)
assertEquals(null, vm.uiState.value.compressionStatus)
val errorMsg =
vm.uiState.value.messages
.lastOrNull { it.role == MessageRole.ASSISTANT }
assertTrue(errorMsg?.content?.contains("network disconnected") == true)
}
@Test
fun `duplicate compress while isCompressing is true is a no-op`() =
runTest {
val (vm, _) = createViewModelWithSession()
var compressCalls = 0
val deferred = CompletableDeferred<Any?>()
every {
HermesWsClient.request(WsMethods.SESSION_COMPRESS, any(), any())
} answers {
compressCalls++
deferred
}
vm.sendMessage("/compress")
testDispatcher.scheduler.runCurrent()
assertTrue(vm.uiState.value.isCompressing)
assertEquals(1, compressCalls)
// Second invocation while in flight
vm.sendMessage("/compress")
testDispatcher.scheduler.runCurrent()
assertEquals("duplicate call should not trigger another RPC", 1, compressCalls)
// Also check /compact is blocked while in flight
vm.sendMessage("/compact")
testDispatcher.scheduler.runCurrent()
assertEquals("compact while compressing should not trigger another RPC", 1, compressCalls)
deferred.complete(null)
advanceUntilIdle()
assertFalse(vm.uiState.value.isCompressing)
}
}
@@ -13,6 +13,8 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
@@ -109,7 +111,8 @@ class ChatConnectionOperationDelegateTest {
assertEquals(1, requester.calls.size)
val call = requester.calls.single()
assertEquals(WsMethods.CONNECTION_RESPOND, call.first)
assertEquals("session-a", call.second["session_id"])
assertEquals(setOf("owner", "op_id", "result"), call.second.keys)
assertEquals(mapOf("type" to "session", "session_id" to "session-a"), call.second["owner"])
assertEquals("op-a", call.second["op_id"])
@Suppress("UNCHECKED_CAST")
val result = call.second["result"] as Map<String, Any>
@@ -177,11 +180,63 @@ class ChatConnectionOperationDelegateTest {
continueDelegate.continueOperation()
assertEquals(
mapOf("settled_by" to "continue"),
continueRequester.calls.single().second["result"],
mapOf(
"owner" to mapOf("type" to "session", "session_id" to "session-a"),
"op_id" to "op-a",
"result" to mapOf("settled_by" to "continue"),
),
continueRequester.calls.single().second,
)
}
@Test
fun wakePayload_sendsSessionOwnerNotTopLevelSessionId() =
runTest {
val requester = RecordingRequester()
val delegate = delegate(requester)
delegate.reset("session-a")
delegate.acceptRequest(snapshot())
delegate.wake("op-a")
val call = requester.calls.single()
assertEquals(WsMethods.CONNECTORS_OPERATION_WAKE, call.first)
assertEquals(
mapOf(
"owner" to mapOf("type" to "session", "session_id" to "session-a"),
"op_id" to "op-a",
),
call.second,
)
}
@Test
fun ownershipAndRuntimeFailures_mapToTypedNonRetryableErrors() =
runTest {
val cases =
listOf(
HermesWsClient.HermesRpcException("x", code = 4001) to
ChatConnectionOperationDelegate.NOT_OWNER,
HermesWsClient.HermesRpcException(
"x",
code = 5033,
data = JsonObject(mapOf("reason" to JsonPrimitive("UNSUPPORTED_RUNTIME"))),
) to ChatConnectionOperationDelegate.UNSUPPORTED_RUNTIME,
)
for ((failure, expected) in cases) {
val delegate = delegate(RecordingRequester(failure = failure))
delegate.reset("session-a")
delegate.acceptRequest(snapshot())
delegate.continueOperation()
val error = delegate.state.value.error
assertEquals(expected, error?.message)
assertFalse(error?.retryable ?: true)
assertNull(delegate.state.value.pendingAction)
}
}
@Test
fun rpcFailureUnlocksWithSanitizedError() =
runTest {
@@ -0,0 +1,118 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.model.SessionMessage
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
/** Issue #1284: history rows carry display_commentary / display_reasoning projections. */
class ChatHistoryCommentaryTest {
private fun row(
content: String,
reasoning: String? = null,
commentary: List<String>? = null,
displayReasoning: String? = null,
id: Int = 7,
) = SessionMessage(
id = id,
role = "assistant",
content = JsonPrimitive(content),
reasoning = reasoning?.let { JsonPrimitive(it) },
display_commentary = commentary?.let { list -> JsonArray(list.map { JsonPrimitive(it) }) },
display_reasoning = displayReasoning?.let { JsonPrimitive(it) as JsonElement },
)
private fun map(vararg rows: SessionMessage) = mapServerMessages("s", rows.toList(), 0, true, emptyList())
private fun countOf(
haystack: String,
needle: String,
) = haystack.windowed(needle.length).count { it == needle }
@Test
fun commentaryRendersOnceBeforeReplyAndLeavesReasoning() {
val commentary = "I will inspect the files."
val message =
map(
row(
content = "Done: two files changed.",
reasoning = "Private plan.\n\n$commentary",
commentary = listOf(commentary),
displayReasoning = "Private plan.",
),
).single()
assertEquals("$commentary\n\nDone: two files changed.", message.content)
assertEquals("Private plan.", message.reasoningText)
assertEquals(1, countOf(message.content + message.reasoningText, commentary))
}
@Test
fun emptyDisplayReasoningWinsOverRawReasoning() {
val commentary = "Checking."
val message =
map(row(content = "", reasoning = commentary, commentary = listOf(commentary), displayReasoning = ""))
.single()
assertEquals(commentary, message.content)
assertEquals("", message.reasoningText)
}
@Test
fun commentaryEqualToReplyIsNotDuplicated() {
val text = "Looking at it now."
val message = map(row(content = text, commentary = listOf(text), displayReasoning = "")).single()
assertEquals(text, message.content)
}
@Test
fun disabledCommentaryKeepsItOutOfTranscriptAndReasoning() {
// Backend policy off: empty list, display_reasoning still strips the raw copy.
val message =
map(
row(
content = "Answer",
reasoning = "Plan.\nSecret interim",
commentary = emptyList(),
displayReasoning = "Plan.",
),
).single()
assertEquals("Answer", message.content)
assertEquals("Plan.", message.reasoningText)
assertFalse(message.content.contains("Secret interim"))
}
@Test
fun olderGatewayFallsBackToRawReasoning() {
val message = map(row(content = "Answer", reasoning = "Raw trace")).single()
assertEquals("Answer", message.content)
assertEquals("Raw trace", message.reasoningText)
}
@Test
fun liveSealedCommentaryBubbleIsCoveredByRestRowNotDuplicated() {
val commentary = "I will inspect the files."
val liveOrphan = ChatMessage(id = "live-orphan", role = MessageRole.ASSISTANT, content = commentary)
val rest =
map(row(content = "", reasoning = commentary, commentary = listOf(commentary), displayReasoning = ""))
val merged = mergeTranscriptWithLive(rest, listOf(liveOrphan), preserveLiveIds = true)
assertEquals(1, merged.count { it.content.contains(commentary) })
assertEquals("", merged.single().reasoningText)
}
@Test
fun multipleCommentaryItemsKeepOrderAndSkipBlanks() {
val message =
map(row(content = "Final", commentary = listOf("First.", " ", "Second."), displayReasoning = "")).single()
assertEquals("First.\n\nSecond.\n\nFinal", message.content)
}
}
@@ -316,7 +316,7 @@ class ChatPagingMergeTest {
val mapped = mapServerMessages("s", listOf(serverTool(42, "call-42")), 0, true, listOf(live, echo))
assertEquals(listOf(live.copy(restId = "rest-s-42")), mapped)
assertEquals(listOf(live.copy(restId = "rest-s-42", serverRowId = 42L)), mapped)
}
@Test
@@ -328,7 +328,10 @@ class ChatPagingMergeTest {
val mapped = mapServerMessages("s", listOf(serverTool(42), serverTool(43)), 0, true, cache)
assertEquals(
listOf(first.copy(restId = "rest-s-42"), second.copy(restId = "rest-s-43")),
listOf(
first.copy(restId = "rest-s-42", serverRowId = 42L),
second.copy(restId = "rest-s-43", serverRowId = 43L),
),
mapped,
)
assertEquals(2, mapped.map { it.id }.toSet().size)
@@ -0,0 +1,179 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.ws.WsEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** #1285: persisted-turn receipts are addresses, never negative acknowledgements. */
class ChatPersistedReceiptsTest {
@Test
fun parsesCompleteReceiptWithMixedNumberTypes() {
val turn =
parsePersistedTurn(
mapOf(
"persisted_turn" to
mapOf(
"row_ids" to listOf(41, 42L, 43.0),
"complete" to true,
"user_row_id" to 41,
"final_assistant_row_id" to 43L,
),
),
)
assertEquals(PersistedTurn(true, 41L, 43L), turn)
assertEquals(
43L,
parsePersistedTurn(mapOf("persisted_turn" to mapOf("final_assistant_row_id" to 43.0)))?.finalAssistantRowId,
)
}
@Test
fun missingIdsStayUnprovenAndMalformedValuesAreIgnored() {
val turn =
parsePersistedTurn(
mapOf(
"persisted_turn" to
mapOf("complete" to "true", "user_row_id" to true, "final_assistant_row_id" to 1.5),
),
)!!
assertFalse(turn.complete)
assertNull(positiveRowId("8"))
assertNull(positiveRowId(0))
assertNull(positiveRowId(-1L))
assertNull(turn.userRowId)
assertNull(turn.finalAssistantRowId)
assertNull(parsePersistedTurn(mapOf("text" to "done")))
assertNull(parsePersistedTurn(null))
}
@Test
fun rowIdMatchesUserEchoEvenWhenServerTextDiffers() {
val local = ChatMessage(id = "local", role = MessageRole.USER, content = "hi", serverRowId = 42)
val rest =
ChatMessage(id = "rest-s-42", role = MessageRole.USER, content = "hi\n\n--- rewritten", serverRowId = 42)
val merged = mergeTranscriptWithLive(listOf(rest), listOf(local), preserveLiveIds = true)
assertEquals(listOf("local"), merged.map { it.id })
assertEquals("rest-s-42", merged.single().restId)
assertEquals(42L, merged.single().serverRowId)
}
@Test
fun differentRowIdsKeepRepeatedTextAsSeparateTurns() {
val local = ChatMessage(id = "local", role = MessageRole.USER, content = "again", serverRowId = 43)
val older = ChatMessage(id = "rest-s-42", role = MessageRole.USER, content = "again", serverRowId = 42)
val merged = mergeTranscriptWithLive(listOf(older), listOf(local), preserveLiveIds = true)
assertEquals(setOf("local", "rest-s-42"), merged.map { it.id }.toSet())
}
@Test
fun missingRowIdFallsBackToContentMatching() {
val local = ChatMessage(id = "local", role = MessageRole.USER, content = "hello")
val rest = ChatMessage(id = "rest-s-9", role = MessageRole.USER, content = "hello", serverRowId = 9)
val merged = mergeTranscriptWithLive(listOf(rest), listOf(local), preserveLiveIds = true)
assertEquals(listOf("local"), merged.map { it.id })
assertEquals(9L, merged.single().serverRowId)
}
@Test
fun completeReceiptBindsFinalAssistantRow() {
val result =
complete(
"answer",
mapOf(
"row_ids" to listOf(1, 2),
"complete" to true,
"final_assistant_row_id" to 2,
),
)
assertEquals(
2L,
result.state.messages
.single()
.serverRowId,
)
}
@Test
fun partialReceiptStillBindsProvenFinalRowButKeepsLocalReply() {
val result =
complete("answer", mapOf("row_ids" to listOf(2), "complete" to false, "final_assistant_row_id" to 2))
assertEquals(
"answer",
result.state.messages
.single()
.content,
)
assertEquals(
2L,
result.state.messages
.single()
.serverRowId,
)
}
@Test
fun receiptWithoutFinalIdLeavesReplyUnbound() {
val result = complete("answer", mapOf("row_ids" to listOf(1), "complete" to false, "user_row_id" to 1))
assertEquals(
"answer",
result.state.messages
.single()
.content,
)
assertNull(
result.state.messages
.single()
.serverRowId,
)
}
@Test
fun strippedCommentaryPrefixDoesNotClaimFinalRow() {
val sealed = ChatMessage(id = "sealed", role = MessageRole.ASSISTANT, content = "Looking. ")
val result =
ChatWsEventReducer.reduce(
state = ChatUiState(currentSessionId = "session-1", messages = listOf(sealed)),
streamingState = StreamingState(sealedOrphanIds = listOf("sealed")),
event =
WsEvent.MessageComplete(
"Looking. Done",
"session-1",
rawPayload =
mapOf(
"persisted_turn" to
mapOf("row_ids" to listOf(5), "complete" to true, "final_assistant_row_id" to 5),
),
),
currentSessionId = "session-1",
)
val reply = result.state.messages.single { it.id != "sealed" }
assertEquals("Done", reply.content)
assertNull(reply.serverRowId)
assertTrue(result.state.messages.none { it.serverRowId == 5L })
}
private fun complete(
text: String,
receipt: Map<String, Any?>,
) = ChatWsEventReducer.reduce(
state = ChatUiState(currentSessionId = "session-1"),
streamingState = StreamingState(),
event = WsEvent.MessageComplete(text, "session-1", rawPayload = mapOf("persisted_turn" to receipt)),
currentSessionId = "session-1",
)
}
@@ -64,6 +64,28 @@ class ChatPromptProtocolTest {
assertEquals("new answer", answers?.get("q1")?.jsonPrimitive?.content)
}
@Test
fun legacyClarifyBatchReplay_skipsLockedAnswers_withoutEmptyOverwrite() {
val responses =
buildLegacyClarifyBatchResponses(
questions =
listOf(
ClarifyQuestionUi(qid = "q0", question = "Already answered?"),
ClarifyQuestionUi(qid = "q1", question = "Still waiting?"),
ClarifyQuestionUi(qid = "q2", question = "Optional skip?"),
),
answers =
mapOf(
"q0" to "",
"q1" to " new answer ",
"q2" to "",
),
lockedAnswers = mapOf("q0" to "yes"),
)
assertEquals(listOf("q1" to "new answer", "q2" to ""), responses)
}
@Test
fun credentialPrompt_serverRequestId_controls_response_and_cancellation() {
val scope = TestScope(dispatcher)
@@ -1,48 +0,0 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.model.BusySendMode
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatQueuePresentationTest {
private val message = ChatMessage("queued-id", MessageRole.USER, "Later")
private fun pending(state: PendingSendState) =
PendingSend(message.id, "scope", "session", message.content, mode = BusySendMode.QUEUE, state = state)
@Test
fun queuedAndParkedMessagesRemainInternalUntilDispatch() {
val messages = listOf(message)
for (state in listOf(PendingSendState.QUEUED, PendingSendState.PARKED)) {
val receipts = listOf(pending(state))
assertTrue(messagesWithoutUnsentQueue(messages, receipts).isEmpty())
assertEquals(listOf(message), messages)
assertEquals(state, receipts.single().state)
}
}
@Test
fun queuedMessageAppearsOnceWhenSendingStarts() {
val messages = listOf(message)
assertTrue(messagesWithoutUnsentQueue(messages, listOf(pending(PendingSendState.QUEUED))).isEmpty())
for (state in listOf(PendingSendState.SENDING, PendingSendState.ACCEPTED)) {
assertEquals(messages, messagesWithoutUnsentQueue(messages, listOf(pending(state))))
}
}
@Test
fun ordinarySendAndOtherMessagesAreUnchanged() {
val other = ChatMessage("other", MessageRole.USER, "Send now")
val messages = listOf(other, message)
assertSame(messages, messagesWithoutUnsentQueue(messages, emptyList()))
assertEquals(listOf(other), messagesWithoutUnsentQueue(messages, listOf(pending(PendingSendState.QUEUED))))
}
@Test
fun canonicalHistoryIsNeverHiddenByStaleQueueReceipt() {
val messages = listOf(message.copy(restId = "rest-session-12"))
assertEquals(messages, messagesWithoutUnsentQueue(messages, listOf(pending(PendingSendState.QUEUED))))
}
}
@@ -761,12 +761,12 @@ class ChatViewModelTest {
id
}
// /fork with an optional branch title.
// /fork with an optional branch title sends session.branch_whole.
viewModel.sendMessage("/fork my-fork")
advanceUntilIdle()
val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH }
assertNotNull("session.branch should be dispatched for /fork", branchSent)
val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE }
assertNotNull("session.branch_whole should be dispatched for /fork", branchSent)
assertEquals(sessionId, branchSent!!.second["session_id"])
assertEquals("my-fork", branchSent.second["name"])
}
@@ -811,12 +811,147 @@ class ChatViewModelTest {
viewModel.sendMessage("/fork")
advanceUntilIdle()
val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH }
assertNotNull("session.branch should be dispatched for /fork", branchSent)
val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE }
assertNotNull("session.branch_whole should be dispatched for /fork", branchSent)
assertEquals(sessionId, branchSent!!.second["session_id"])
assertFalse("name param should be omitted when no title given", branchSent.second.containsKey("name"))
}
@Test
fun testSlashCommand_fork_branchWholeSuccess_handledLikeSessionBranch() =
runTest {
val (viewModel, _) = createViewModelWithSession()
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.sendMessage("/fork")
advanceUntilIdle()
val branchWholeSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE }
assertNotNull("session.branch_whole dispatched", branchWholeSent)
mockEventsFlow.emit(
WsEvent.RpcResult(
"req-1",
mapOf(
"session_id" to "runtime-branch-whole",
"stored_session_id" to "stored-branch-whole",
"title" to "Whole Branch",
),
),
)
advanceUntilIdle()
assertEquals("runtime-branch-whole", ActiveSessionHolder.activeSessionId.value)
assertEquals("stored-branch-whole", viewModel.uiState.value.currentSessionId)
assertTrue(
"System message 'Session branched' should be added",
viewModel.uiState.value.messages
.any { it.content.contains("Session branched") },
)
// No fallback was sent
assertEquals(0, captured.count { it.first == WsMethods.SESSION_BRANCH })
assertEquals(1, captured.count { it.first == WsMethods.SESSION_BRANCH_WHOLE })
}
@Test
fun testSlashCommand_fork_branchWholeError32601_retriesWithSessionBranch() =
runTest {
val (viewModel, sessionId) = createViewModelWithSession()
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.sendMessage("/fork my-retry-branch")
advanceUntilIdle()
val branchWholeSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE }
assertNotNull("session.branch_whole should be sent", branchWholeSent)
assertEquals("my-retry-branch", branchWholeSent!!.second["name"])
assertEquals(sessionId, branchWholeSent.second["session_id"])
// Backend answers -32601 unknown method
mockEventsFlow.emit(
WsEvent.RpcError(
"req-1",
JsonRpcError(code = -32601, message = "unknown method: session.branch_whole"),
),
)
advanceUntilIdle()
// Retries ONCE with session.branch using same params
val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH }
assertNotNull("session.branch fallback should be sent", branchSent)
assertEquals("my-retry-branch", branchSent!!.second["name"])
assertEquals(sessionId, branchSent.second["session_id"])
assertEquals(1, captured.count { it.first == WsMethods.SESSION_BRANCH })
// session.branch result is then handled
mockEventsFlow.emit(
WsEvent.RpcResult(
"req-2",
mapOf(
"session_id" to "runtime-branch-fallback",
"stored_session_id" to "stored-branch-fallback",
"title" to "my-retry-branch",
),
),
)
advanceUntilIdle()
assertEquals("runtime-branch-fallback", ActiveSessionHolder.activeSessionId.value)
assertEquals("stored-branch-fallback", viewModel.uiState.value.currentSessionId)
assertTrue(
"System message 'Session branched' should be added",
viewModel.uiState.value.messages
.any { it.content.contains("Session branched") },
)
}
@Test
fun testSlashCommand_fork_branchWholeOtherError_noFallback() =
runTest {
val (viewModel, _) = createViewModelWithSession()
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.sendMessage("/fork")
advanceUntilIdle()
val branchWholeSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE }
assertNotNull("session.branch_whole should be sent", branchWholeSent)
// Backend answers with code other than -32601
mockEventsFlow.emit(
WsEvent.RpcError(
"req-1",
JsonRpcError(code = -32600, message = "invalid request"),
),
)
advanceUntilIdle()
// No retry/fallback sent
assertEquals(0, captured.count { it.first == WsMethods.SESSION_BRANCH })
assertTrue(
viewModel.uiState.value.errorMessage
?.contains("session.branch_whole") == true,
)
}
@Test
fun testSlashCommand_stop_sendsInterrupt() =
runTest {
@@ -3516,6 +3651,112 @@ class ChatViewModelTest {
assertEquals(PendingSendState.UNKNOWN, sendStore.all().single().state)
}
@Test
fun completePersistedTurnRetiresOnlyTheReceiptBoundToItsUserRow() =
runTest {
val sendStore = ChatSendStore()
val (viewModel, sessionId) = createViewModelWithSession(sendStore = sendStore)
every { HermesWsClient.sendMessage(sessionId, "Durable", any(), any()) } answers {
arg<((String) -> Unit)?>(2)?.invoke("durable-submit")
"durable-submit"
}
viewModel.sendMessage("Durable")
advanceUntilIdle()
mockEventsFlow.emit(
WsEvent.RpcResult("durable-submit", mapOf("status" to "streaming", "user_row_id" to 77)),
)
advanceUntilIdle()
val receipt = sendStore.all().single()
assertEquals(77L, receipt.userRowId)
assertEquals(
77L,
viewModel.uiState.value.messages
.single { it.id == receipt.id }
.serverRowId,
)
// Partial and foreign receipts never retire the local turn.
mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
mockEventsFlow.emit(
WsEvent.MessageComplete(
"Reply",
sessionId,
rawPayload =
mapOf(
"persisted_turn" to
mapOf(
"row_ids" to listOf(77),
"complete" to false,
"user_row_id" to 77,
),
),
),
)
advanceUntilIdle()
assertEquals(listOf(receipt.id), sendStore.all().map { it.id })
mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
mockEventsFlow.emit(
WsEvent.MessageComplete(
"Reply",
sessionId,
rawPayload =
mapOf(
"persisted_turn" to mapOf("row_ids" to listOf(78), "complete" to true, "user_row_id" to 78),
),
),
)
advanceUntilIdle()
assertEquals(listOf(receipt.id), sendStore.all().map { it.id })
mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
mockEventsFlow.emit(
WsEvent.MessageComplete(
"Reply",
sessionId,
rawPayload =
mapOf(
"persisted_turn" to
mapOf(
"row_ids" to listOf(77, 79),
"complete" to true,
"user_row_id" to 77,
"final_assistant_row_id" to 79,
),
),
),
)
advanceUntilIdle()
assertTrue(sendStore.all().isEmpty())
assertTrue(
viewModel.uiState.value.messages
.any { it.id == receipt.id },
)
}
@Test
fun submitAckWithoutUserRowIdLeavesReceiptUnproven() =
runTest {
val sendStore = ChatSendStore()
val (viewModel, sessionId) = createViewModelWithSession(sendStore = sendStore)
every { HermesWsClient.sendMessage(sessionId, "Legacy", any(), any()) } answers {
arg<((String) -> Unit)?>(2)?.invoke("legacy-submit")
"legacy-submit"
}
viewModel.sendMessage("Legacy")
advanceUntilIdle()
mockEventsFlow.emit(WsEvent.RpcResult("legacy-submit", mapOf("status" to "streaming")))
advanceUntilIdle()
val receipt = sendStore.all().single()
assertNull(receipt.userRowId)
assertEquals(PendingSendState.ACCEPTED, receipt.state)
assertTrue(
viewModel.uiState.value.messages
.any { it.id == receipt.id && it.serverRowId == null },
)
}
@Test
fun busyGuideUsesSteerAndUnsupportedFallsBackToQueueWithoutInterrupt() =
runTest {
@@ -5128,7 +5369,7 @@ class ChatViewModelTest {
viewModel.sendMessage("/fork")
runCurrent()
val branchRequest = captured.last { it.first == WsMethods.SESSION_BRANCH }.second
val branchRequest = captured.last { it.first == WsMethods.SESSION_BRANCH_WHOLE }.second
mockEventsFlow.emit(
WsEvent.RpcResult(
branchRequest,
@@ -5962,11 +6203,11 @@ class ChatViewModelTest {
.SessionMessagesResponse(messages = emptyList()),
)
// /fork sends session.branch keyed on the runtime id.
// /fork sends session.branch_whole keyed on the runtime id.
viewModel.sendMessage("/fork")
advanceUntilIdle()
val branchId = captured.last { it.first == WsMethods.SESSION_BRANCH }.second
val branchId = captured.last { it.first == WsMethods.SESSION_BRANCH_WHOLE }.second
val branchStorage = "branch-storage-1"
mockEventsFlow.emit(
WsEvent.RpcResult(
@@ -1478,4 +1478,131 @@ class ChatWsEventReducerTest {
assertTrue(result.state.messages.none { it.id != "orphan" })
assertTrue(!result.streamingState.turnUsageBaselineCaptured)
}
@Test
fun testStatusUpdate_compressingState() {
val initialMessage = ChatMessage(role = MessageRole.USER, content = "hello")
val state =
ChatUiState(
currentSessionId = "session-1",
messages = listOf(initialMessage),
isCompressing = false,
compressionStatus = null,
)
val event =
WsEvent.StatusUpdate(
status = null,
data =
mapOf(
"kind" to "compressing",
"text" to "⏳ Compressing context...",
),
)
val result =
ChatWsEventReducer.reduce(
state = state,
streamingState = StreamingState(),
event = event,
currentSessionId = "session-1",
)
assertTrue(result.state.isCompressing)
assertEquals("⏳ Compressing context...", result.state.compressionStatus)
assertEquals(listOf(initialMessage), result.state.messages)
}
@Test
fun testStatusUpdate_compactingState() {
val state =
ChatUiState(
currentSessionId = "session-1",
isCompressing = false,
compressionStatus = null,
)
val event =
WsEvent.StatusUpdate(
status = null,
data =
mapOf(
"kind" to "compacting",
"text" to "🗜️ Compacting...",
),
)
val result =
ChatWsEventReducer.reduce(
state = state,
streamingState = StreamingState(),
event = event,
currentSessionId = "session-1",
)
assertTrue(result.state.isCompressing)
assertEquals("🗜️ Compacting...", result.state.compressionStatus)
}
@Test
fun testStatusUpdate_compactedState() {
val state =
ChatUiState(
currentSessionId = "session-1",
isCompressing = true,
compressionStatus = "🗜️ Compacting...",
)
val event =
WsEvent.StatusUpdate(
status = null,
data =
mapOf(
"kind" to "compacted",
"text" to "Finished",
),
)
val result =
ChatWsEventReducer.reduce(
state = state,
streamingState = StreamingState(),
event = event,
currentSessionId = "session-1",
)
assertFalse(result.state.isCompressing)
// Accepts either "Finished" or null if cleared
assertTrue(result.state.compressionStatus == "Finished" || result.state.compressionStatus == null)
}
@Test
fun testStatusUpdate_unrelatedKindIgnored() {
val initialMessages = listOf(ChatMessage(role = MessageRole.USER, content = "test"))
val state =
ChatUiState(
currentSessionId = "session-1",
messages = initialMessages,
isCompressing = false,
compressionStatus = "idle",
)
val event =
WsEvent.StatusUpdate(
status = null,
data =
mapOf(
"kind" to "other",
"text" to "Something else",
),
)
val result =
ChatWsEventReducer.reduce(
state = state,
streamingState = StreamingState(),
event = event,
currentSessionId = "session-1",
)
assertFalse(result.state.isCompressing)
assertEquals("idle", result.state.compressionStatus)
assertEquals(initialMessages, result.state.messages)
}
}
@@ -0,0 +1,327 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.model.SessionMessage
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Tests verifying that persisted edit previews survive transcript loading into [ChatMessage]
* and subsequent tool diff parsing via [parseToolOutput].
*/
class PersistedEditPreviewHydrationTest {
private val json = Json { ignoreUnknownKeys = true }
private fun parseJson(raw: String): JsonElement = json.parseToJsonElement(raw)
private fun toolSessionMessage(
content: String,
displayMetadata: JsonElement? = null,
id: Int = 101,
toolCallId: String = "call-101",
): SessionMessage =
SessionMessage(
id = id,
role = "tool",
content = JsonPrimitive(content),
tool_call_id = toolCallId,
display_metadata = displayMetadata,
)
private fun mapSingle(msg: SessionMessage): ChatMessage =
mapServerMessages(
sessionId = "sess-edit-preview",
messages = listOf(msg),
offset = 0,
latestPaging = true,
liveMessages = emptyList(),
).single()
// ── Contract 1: ChatServerMessageMapper enrichment ────────────────────────
@Test
fun mapServerMessages_enrichesToolContentWithDisplayMetadataInlineDiff() {
val diffPayload =
"""
{
"tool_result_metadata": {
"inline_diff": "@@ -1 +1 @@\n-old\n+new"
}
}
""".trimIndent()
val originalContent = """{"output":"File updated successfully"}"""
val serverMsg =
toolSessionMessage(
content = originalContent,
displayMetadata = parseJson(diffPayload),
)
val chatMessage = mapSingle(serverMsg)
val enriched = parseJson(chatMessage.content) as? JsonObject
assertNotNull("Expected enriched content to parse as JSON object", enriched)
assertEquals(
"File updated successfully",
(enriched?.get("output") as? JsonPrimitive)?.content,
)
val displayMeta = enriched?.get("display_metadata") as? JsonObject
assertNotNull("Expected display_metadata to be added to content", displayMeta)
val toolResultMeta = displayMeta?.get("tool_result_metadata") as? JsonObject
assertNotNull("Expected tool_result_metadata inside display_metadata", toolResultMeta)
val diff = (toolResultMeta?.get("inline_diff") as? JsonPrimitive)?.content
assertEquals("@@ -1 +1 @@\n-old\n+new", diff)
}
@Test
fun mapServerMessages_doesNotEnrichWhenDisplayMetadataIsNull() {
val originalContent = """{"output":"no metadata"}"""
val serverMsg =
toolSessionMessage(
content = originalContent,
displayMetadata = null,
)
val chatMessage = mapSingle(serverMsg)
assertEquals(originalContent, chatMessage.content)
}
@Test
fun mapServerMessages_doesNotEnrichWhenRoleIsNotTool() {
val metadata =
"""
{
"tool_result_metadata": {
"inline_diff": "@@ -1 +1 @@\n-old\n+new"
}
}
""".trimIndent()
val originalContent = "Assistant says hello"
val serverMsg =
SessionMessage(
id = 202,
role = "assistant",
content = JsonPrimitive(originalContent),
display_metadata = parseJson(metadata),
)
val chatMessage = mapSingle(serverMsg)
assertEquals(originalContent, chatMessage.content)
}
@Test
fun mapServerMessages_doesNotOverwriteExistingDisplayMetadataInContent() {
val content =
"""
{
"output": "ok",
"display_metadata": {
"existing": true
}
}
""".trimIndent()
val externalMetadata =
"""
{
"tool_result_metadata": {
"inline_diff": "@@ -1 +1 @@\n-old\n+new"
}
}
""".trimIndent()
val serverMsg =
toolSessionMessage(
content = content,
displayMetadata = parseJson(externalMetadata),
)
val chatMessage = mapSingle(serverMsg)
val parsed = parseJson(chatMessage.content) as? JsonObject
assertNotNull(parsed)
val meta = parsed?.get("display_metadata") as? JsonObject
assertEquals("true", (meta?.get("existing") as? JsonPrimitive)?.content)
assertNull(meta?.get("tool_result_metadata"))
}
@Test
fun mapServerMessages_ignoresNonJsonObjectContent() {
val nonJsonContent = "Plain text output from tool"
val metadata =
"""
{
"tool_result_metadata": {
"inline_diff": "@@ -1 +1 @@\n-old\n+new"
}
}
""".trimIndent()
val serverMsg =
toolSessionMessage(
content = nonJsonContent,
displayMetadata = parseJson(metadata),
)
val chatMessage = mapSingle(serverMsg)
assertEquals(nonJsonContent, chatMessage.content)
}
// ── Contract 2: ToolResultParser / FileRenderers inline diff extraction ──
@Test
fun parseToolOutput_extractsInlineDiffFromNestedToolResultMetadata() {
val rawDiff = "@@ -1,3 +1,3 @@\n-old line\n+new line"
val content =
"""
{
"name": "patch",
"args": {
"path": "app/src/main/Utils.kt"
},
"display_metadata": {
"tool_result_metadata": {
"inline_diff": "@@ -1,3 +1,3 @@\n-old line\n+new line"
}
}
}
""".trimIndent()
val view = parseToolOutput(content = content, toolName = "patch", isRunning = false)
assertNotNull("Expected parseToolOutput to return a ToolView", view)
assertEquals(rawDiff, view?.inlineDiff)
assertEquals("app/src/main/Utils.kt", view?.diffPath)
assertEquals(1, view?.diffStats?.added)
assertEquals(1, view?.diffStats?.removed)
}
@Test
fun parseToolOutput_extractsInlineDiffFromFlatDisplayMetadata() {
val rawDiff = "--- a/test.kt\n+++ b/test.kt\n@@ -1 +1 @@\n-alpha\n+beta"
val content =
"""
{
"name": "edit_file",
"args": {
"path": "test.kt"
},
"display_metadata": {
"inline_diff": "--- a/test.kt\n+++ b/test.kt\n@@ -1 +1 @@\n-alpha\n+beta"
}
}
""".trimIndent()
val view = parseToolOutput(content = content, toolName = "edit_file", isRunning = false)
assertNotNull(view)
assertEquals(rawDiff, view?.inlineDiff)
assertEquals("test.kt", view?.diffPath)
assertEquals(1, view?.diffStats?.added)
assertEquals(1, view?.diffStats?.removed)
}
@Test
fun parseToolOutput_extractsInlineDiffFromResultObjectDirectly() {
val rawDiff = "@@ -1 +1 @@\n-before\n+after"
val content =
"""
{
"name": "patch",
"args": {
"path": "file.txt"
},
"result": {
"inline_diff": "@@ -1 +1 @@\n-before\n+after"
}
}
""".trimIndent()
val view = parseToolOutput(content = content, toolName = "patch", isRunning = false)
assertNotNull(view)
assertEquals(rawDiff, view?.inlineDiff)
assertEquals("file.txt", view?.diffPath)
assertEquals(1, view?.diffStats?.added)
assertEquals(1, view?.diffStats?.removed)
}
@Test
fun parseToolOutput_absentOrEmptyMetadataYieldsNullInlineDiff() {
val emptyDiffContent =
"""
{
"name": "patch",
"args": {
"path": "file.txt"
},
"display_metadata": {
"tool_result_metadata": {
"inline_diff": ""
}
}
}
""".trimIndent()
val noDiffView = parseToolOutput(content = emptyDiffContent, toolName = "patch", isRunning = false)
assertNotNull(noDiffView)
assertNull(noDiffView?.inlineDiff)
val missingDiffContent =
"""
{
"name": "patch",
"args": {
"path": "file.txt"
},
"result": {
"success": true
}
}
""".trimIndent()
val missingDiffView = parseToolOutput(content = missingDiffContent, toolName = "patch", isRunning = false)
assertNotNull(missingDiffView)
assertNull(missingDiffView?.inlineDiff)
}
// ── End-to-end integration: Transcript hydration -> ToolView diff parsing ─
@Test
fun endToEnd_hydratedTranscriptProducesToolViewWithInlineDiff() {
val serverDiff = "@@ -1,2 +1,2 @@\n-val x = 1\n+val x = 2"
val sessionMsg =
toolSessionMessage(
content = """{"name":"patch","args":{"path":"src/Config.kt"},"result":{"success":true}}""",
displayMetadata =
parseJson(
"""
{
"tool_result_metadata": {
"inline_diff": "@@ -1,2 +1,2 @@\n-val x = 1\n+val x = 2"
}
}
""".trimIndent(),
),
)
val chatMessage = mapSingle(sessionMsg)
assertTrue(chatMessage.content.contains("display_metadata"))
val toolView =
parseToolOutput(
content = chatMessage.content,
toolName = "patch",
isRunning = false,
)
assertNotNull("Expected parseToolOutput to succeed for hydrated message content", toolView)
assertEquals(serverDiff, toolView?.inlineDiff)
assertEquals("src/Config.kt", toolView?.diffPath)
assertEquals(1, toolView?.diffStats?.added)
assertEquals(1, toolView?.diffStats?.removed)
}
}
@@ -98,6 +98,15 @@ class SlashCommandDispatcherTest {
assertEquals(SlashResult.Undo(""), dispatcher.dispatch("/Undo"))
}
@Test
fun `compress and compact route to Compress`() {
assertEquals(SlashResult.Compress(""), dispatcher.dispatch("/compress"))
assertEquals(SlashResult.Compress(""), dispatcher.dispatch("/compact"))
assertEquals(SlashResult.Compress("auth decisions"), dispatcher.dispatch("/compress auth decisions"))
assertEquals(SlashResult.Compress("auth decisions"), dispatcher.dispatch("/compact auth decisions"))
assertEquals(SlashResult.Compress(""), dispatcher.dispatch("/compress "))
}
@Test
fun `NEW uppercase still routes to NewSession`() {
// Dispatcher lower-cases before matching, so case must not matter.
@@ -0,0 +1,458 @@
package com.m57.hermescontrol.ui.chat.components
import android.content.Context
import com.m57.hermescontrol.data.model.TtsSpeakRequest
import com.m57.hermescontrol.data.model.TtsSpeakResponse
import com.m57.hermescontrol.data.remote.HermesApiService
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import retrofit2.Response
import java.io.File
import java.io.IOException
import java.util.Base64
@OptIn(ExperimentalCoroutinesApi::class)
class AndroidSpeechTest {
@get:Rule
val tempFolder = TemporaryFolder()
private fun createContext(): Context {
val mockContext = mockk<Context>()
every { mockContext.applicationContext } returns mockContext
every { mockContext.cacheDir } returns tempFolder.root
return mockContext
}
private fun listTempFiles(): List<File> =
tempFolder.root
.walkTopDown()
.filter { it.isFile }
.toList()
@Test
fun `synthesize decodes small real base64 audio bytes to exact file bytes and passes text and profile`() =
runTest {
val context = createContext()
val api = mockk<HermesApiService>()
val expectedBytes = byteArrayOf(0x52, 0x49, 0x46, 0x46, 0x24, 0x00, 0x00, 0x00)
val base64Data = Base64.getEncoder().encodeToString(expectedBytes)
val dataUrl = "data:audio/wav;base64,$base64Data"
coEvery { api.speakText(any(), any()) } returns
Response.success(
TtsSpeakResponse(
ok = true,
data_url = dataUrl,
mime_type = "audio/wav",
provider = "piper",
),
)
val synthesizer: SpeechSynthesizer =
AndroidSpeechSynthesizer(
context = context,
api = api,
profile = "work-profile",
)
val request =
SpeechRequest(
scopeKey = "session-42",
messageId = "msg-101",
text = "Hello world",
)
val resultAudio: SpeechAudio = synthesizer.synthesize(request)
assertTrue("Expected result to be FileSpeechAudio", resultAudio is FileSpeechAudio)
val fileAudio = resultAudio as FileSpeechAudio
val audioFile = fileAudio.file
assertTrue("Synthesized audio file must exist", audioFile.exists())
assertArrayEquals("Decoded file bytes must match input bytes", expectedBytes, audioFile.readBytes())
coVerify(exactly = 1) {
api.speakText(
request = TtsSpeakRequest(text = "Hello world"),
profile = "work-profile",
)
}
fileAudio.dispose()
assertFalse("File must be deleted after dispose()", audioFile.exists())
}
@Test
fun `synthesize passes null profile when synthesizer constructed without profile`() =
runTest {
val context = createContext()
val api = mockk<HermesApiService>()
val rawBytes = byteArrayOf(0x01, 0x02, 0x03, 0x04)
val base64Data = Base64.getEncoder().encodeToString(rawBytes)
coEvery { api.speakText(any(), any()) } returns
Response.success(
TtsSpeakResponse(
ok = true,
data_url = "data:audio/mp3;base64,$base64Data",
mime_type = "audio/mp3",
provider = null,
),
)
val synthesizer: SpeechSynthesizer =
AndroidSpeechSynthesizer(
context = context,
api = api,
profile = null,
)
val request =
SpeechRequest(
scopeKey = "session-1",
messageId = "msg-1",
text = "Testing null profile",
)
val audio = synthesizer.synthesize(request)
assertTrue(audio is FileSpeechAudio)
coVerify(exactly = 1) {
api.speakText(
request = TtsSpeakRequest(text = "Testing null profile"),
profile = null,
)
}
audio.dispose()
assertEquals(0, listTempFiles().size)
}
@Test
fun `HTTP or network failure throws exception and leaves no temp files`() =
runTest {
val context = createContext()
val api = mockk<HermesApiService>()
coEvery { api.speakText(any(), any()) } throws IOException("Network connection lost")
val synthesizer: SpeechSynthesizer =
AndroidSpeechSynthesizer(
context = context,
api = api,
profile = "test-profile",
)
val request =
SpeechRequest(
scopeKey = "session-err",
messageId = "msg-err",
text = "Network fail",
)
try {
synthesizer.synthesize(request)
fail("Expected IOException to be thrown")
} catch (e: Exception) {
assertNotNull(e)
}
assertEquals("No temp files should remain after network failure", 0, listTempFiles().size)
}
@Test
fun `response with ok false throws exception and leaves no temp files`() =
runTest {
val context = createContext()
val api = mockk<HermesApiService>()
coEvery { api.speakText(any(), any()) } returns
Response.success(
TtsSpeakResponse(
ok = false,
data_url = null,
mime_type = null,
provider = null,
),
)
val synthesizer: SpeechSynthesizer =
AndroidSpeechSynthesizer(
context = context,
api = api,
profile = "profile-1",
)
val request =
SpeechRequest(
scopeKey = "s1",
messageId = "m1",
text = "Failed ok=false",
)
try {
synthesizer.synthesize(request)
fail("Expected synthesize to fail when ok is false")
} catch (e: Exception) {
assertNotNull(e)
}
assertEquals("No temp files should remain after ok=false", 0, listTempFiles().size)
}
@Test
fun `malformed data URI scheme throws exception and leaves no temp files`() =
runTest {
val context = createContext()
val api = mockk<HermesApiService>()
coEvery { api.speakText(any(), any()) } returns
Response.success(
TtsSpeakResponse(
ok = true,
data_url = "not-a-data-uri",
mime_type = "audio/wav",
provider = "piper",
),
)
val synthesizer: SpeechSynthesizer =
AndroidSpeechSynthesizer(
context = context,
api = api,
profile = null,
)
val request =
SpeechRequest(
scopeKey = "s1",
messageId = "m1",
text = "Malformed scheme",
)
try {
synthesizer.synthesize(request)
fail("Expected failure for malformed data URI")
} catch (e: Exception) {
assertNotNull(e)
}
assertEquals("No temp files should remain after malformed URI failure", 0, listTempFiles().size)
}
@Test
fun `non-audio data URI throws exception and leaves no temp files`() =
runTest {
val context = createContext()
val api = mockk<HermesApiService>()
val textBase64 = Base64.getEncoder().encodeToString("hello world".toByteArray())
coEvery { api.speakText(any(), any()) } returns
Response.success(
TtsSpeakResponse(
ok = true,
data_url = "data:text/plain;base64,$textBase64",
mime_type = "text/plain",
provider = "text",
),
)
val synthesizer: SpeechSynthesizer =
AndroidSpeechSynthesizer(
context = context,
api = api,
profile = null,
)
val request =
SpeechRequest(
scopeKey = "s1",
messageId = "m1",
text = "Non audio data URI",
)
try {
synthesizer.synthesize(request)
fail("Expected failure for non-audio data URI")
} catch (e: Exception) {
assertNotNull(e)
}
assertEquals("No temp files should remain after non-audio data URI failure", 0, listTempFiles().size)
}
@Test
fun `invalid base64 payload throws exception and leaves no temp files`() =
runTest {
val context = createContext()
val api = mockk<HermesApiService>()
coEvery { api.speakText(any(), any()) } returns
Response.success(
TtsSpeakResponse(
ok = true,
data_url = "data:audio/wav;base64,!!!NotBase64Chars!!!",
mime_type = "audio/wav",
provider = "piper",
),
)
val synthesizer: SpeechSynthesizer =
AndroidSpeechSynthesizer(
context = context,
api = api,
profile = null,
)
val request =
SpeechRequest(
scopeKey = "s1",
messageId = "m1",
text = "Invalid base64",
)
try {
synthesizer.synthesize(request)
fail("Expected failure for invalid base64")
} catch (e: Exception) {
assertNotNull(e)
}
assertEquals("No temp files should remain after base64 decoding failure", 0, listTempFiles().size)
}
@Test
fun `empty data URI payload throws exception and leaves no temp files`() =
runTest {
val context = createContext()
val api = mockk<HermesApiService>()
coEvery { api.speakText(any(), any()) } returns
Response.success(
TtsSpeakResponse(
ok = true,
data_url = "data:audio/wav;base64,",
mime_type = "audio/wav",
provider = "piper",
),
)
val synthesizer: SpeechSynthesizer =
AndroidSpeechSynthesizer(
context = context,
api = api,
profile = null,
)
val request =
SpeechRequest(
scopeKey = "s1",
messageId = "m1",
text = "Empty payload",
)
try {
synthesizer.synthesize(request)
fail("Expected failure for empty base64 payload")
} catch (e: Exception) {
assertNotNull(e)
}
assertEquals("No temp files should remain after empty payload failure", 0, listTempFiles().size)
}
@Test
fun `null data_url throws exception and leaves no temp files`() =
runTest {
val context = createContext()
val api = mockk<HermesApiService>()
coEvery { api.speakText(any(), any()) } returns
Response.success(
TtsSpeakResponse(
ok = true,
data_url = null,
mime_type = "audio/wav",
provider = "piper",
),
)
val synthesizer: SpeechSynthesizer =
AndroidSpeechSynthesizer(
context = context,
api = api,
profile = null,
)
val request =
SpeechRequest(
scopeKey = "s1",
messageId = "m1",
text = "Null data url",
)
try {
synthesizer.synthesize(request)
fail("Expected failure when data_url is null")
} catch (e: Exception) {
assertNotNull(e)
}
assertEquals("No temp files should remain after null data_url failure", 0, listTempFiles().size)
}
@Test
fun `padded valid base64 followed by garbage throws exception and leaves no temp files`() =
runTest {
val context = createContext()
val api = mockk<HermesApiService>()
val validPaddedBase64 = "AQ==" // 1 byte decoded (0x01), padded with ==
val payloadWithTrailingGarbage = validPaddedBase64 + "garbage"
coEvery { api.speakText(any(), any()) } returns
Response.success(
TtsSpeakResponse(
ok = true,
data_url = "data:audio/wav;base64,$payloadWithTrailingGarbage",
mime_type = "audio/wav",
provider = "piper",
),
)
val synthesizer: SpeechSynthesizer =
AndroidSpeechSynthesizer(
context = context,
api = api,
profile = null,
)
val request =
SpeechRequest(
scopeKey = "s1",
messageId = "m1",
text = "Garbage after padding",
)
try {
synthesizer.synthesize(request)
fail("Expected failure for base64 with trailing garbage after padding")
} catch (e: Exception) {
assertNotNull(e)
}
assertEquals("No temp files should remain after trailing garbage failure", 0, listTempFiles().size)
}
}
@@ -0,0 +1,738 @@
package com.m57.hermescontrol.ui.chat.components
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withContext
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class SpeechControllerTest {
private class FakeAudio(
val tag: String,
) : SpeechAudio {
var isDisposed: Boolean = false
private set
override fun dispose() {
isDisposed = true
}
}
private class FakeSynthesizer : SpeechSynthesizer {
var calls: MutableList<SpeechRequest> = mutableListOf()
var synthesizeBlock: (suspend (SpeechRequest) -> SpeechAudio)? = null
override suspend fun synthesize(request: SpeechRequest): SpeechAudio {
calls.add(request)
return synthesizeBlock?.invoke(request) ?: FakeAudio(request.text)
}
}
private class FakePlayer : SpeechPlayer {
var currentlyPlaying: SpeechAudio? = null
var lastEndedCallback: (() -> Unit)? = null
var lastErrorCallback: (() -> Unit)? = null
var playCallCount: Int = 0
var stopCallCount: Int = 0
var playThrows: Throwable? = null
override fun play(
audio: SpeechAudio,
onEnded: () -> Unit,
onError: () -> Unit,
) {
playCallCount++
currentlyPlaying = audio
lastEndedCallback = onEnded
lastErrorCallback = onError
playThrows?.let { throw it }
}
override fun stop() {
stopCallCount++
currentlyPlaying = null
lastEndedCallback = null
lastErrorCallback = null
}
}
private class Fixture(
val scope: TestScope,
) {
val synthesizer = FakeSynthesizer()
val player = FakePlayer()
val errors = mutableListOf<Unit>()
val controller =
SpeechController(
scope = scope,
synthesizer = synthesizer,
player = player,
onError = { errors.add(Unit) },
)
}
@Test
fun `toggle with blank text is a no-op`() =
runTest {
val fixture = Fixture(this)
val blankReq = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = " ")
fixture.controller.toggle(blankReq)
advanceUntilIdle()
assertNull(fixture.controller.speakingId.value)
assertEquals(0, fixture.synthesizer.calls.size)
assertEquals(0, fixture.player.playCallCount)
assertEquals(0, fixture.errors.size)
}
@Test
fun `toggle with empty text is a no-op`() =
runTest {
val fixture = Fixture(this)
val emptyReq = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "")
fixture.controller.toggle(emptyReq)
advanceUntilIdle()
assertNull(fixture.controller.speakingId.value)
assertEquals(0, fixture.synthesizer.calls.size)
assertEquals(0, fixture.player.playCallCount)
assertEquals(0, fixture.errors.size)
}
@Test
fun `toggle synthesizes and plays audio, setting speakingId`() =
runTest {
val fixture = Fixture(this)
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world")
fixture.controller.toggle(req)
advanceUntilIdle()
assertEquals("msg-1", fixture.controller.speakingId.value)
assertEquals(1, fixture.synthesizer.calls.size)
assertEquals(req, fixture.synthesizer.calls[0])
assertEquals(1, fixture.player.playCallCount)
assertEquals(0, fixture.errors.size)
}
@Test
fun `toggle on same active request stops playback and clears speakingId`() =
runTest {
val fixture = Fixture(this)
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world")
fixture.controller.toggle(req)
advanceUntilIdle()
assertEquals("msg-1", fixture.controller.speakingId.value)
fixture.controller.toggle(req)
advanceUntilIdle()
assertNull(fixture.controller.speakingId.value)
assertTrue(fixture.player.stopCallCount >= 1)
}
@Test
fun `toggle on same active request while still synthesizing stops and cancels synthesis`() =
runTest {
val fixture = Fixture(this)
val gate = CompletableDeferred<SpeechAudio>()
fixture.synthesizer.synthesizeBlock = { gate.await() }
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world")
fixture.controller.toggle(req)
runCurrent()
// Toggle same request while synthesis is inflight
fixture.controller.toggle(req)
runCurrent()
assertNull(fixture.controller.speakingId.value)
// Even if synthesis completes later, generation fencing ensures it never plays
val audio = FakeAudio("Hello world")
gate.complete(audio)
advanceUntilIdle()
assertEquals(0, fixture.player.playCallCount)
assertNull(fixture.controller.speakingId.value)
assertEquals(0, fixture.errors.size)
}
@Test
fun `noncooperative synthesis cancelled during synthesis still disposes returned audio without playing`() =
runTest {
val fixture = Fixture(this)
val gate = CompletableDeferred<Unit>()
val resumeGate = CompletableDeferred<Unit>()
val audio = FakeAudio("Stale noncooperative audio")
fixture.synthesizer.synthesizeBlock = {
try {
gate.await()
} catch (t: Throwable) {
withContext(NonCancellable) {
resumeGate.await()
}
}
audio
}
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First text")
fixture.controller.toggle(req)
runCurrent()
// Cancel by stopping
fixture.controller.stop()
runCurrent()
assertNull(fixture.controller.speakingId.value)
// Now let noncooperative synthesis produce audio after cancellation
resumeGate.complete(Unit)
advanceUntilIdle()
// Audio must be disposed, never played, no error emitted, and speakingId remains null
assertTrue(audio.isDisposed)
assertEquals(0, fixture.player.playCallCount)
assertNull(fixture.controller.speakingId.value)
assertEquals(0, fixture.errors.size)
}
@Test
fun `noncooperative synthesis superseded by replacement disposes stale audio without touching newer state`() =
runTest {
val fixture = Fixture(this)
val gate1 = CompletableDeferred<Unit>()
val resumeGate1 = CompletableDeferred<Unit>()
val audio1 = FakeAudio("Stale noncooperative audio 1")
val audio2 = FakeAudio("New active audio 2")
fixture.synthesizer.synthesizeBlock = { req ->
if (req.messageId == "msg-1") {
try {
gate1.await()
} catch (t: Throwable) {
withContext(NonCancellable) {
resumeGate1.await()
}
}
audio1
} else {
audio2
}
}
val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First text")
val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second text")
fixture.controller.toggle(req1)
runCurrent()
// Supersede with req2
fixture.controller.toggle(req2)
advanceUntilIdle()
// req2 should now be playing
assertEquals("msg-2", fixture.controller.speakingId.value)
assertEquals(audio2, fixture.player.currentlyPlaying)
assertEquals(1, fixture.player.playCallCount)
// Now release stale noncooperative synthesis from req1
resumeGate1.complete(Unit)
advanceUntilIdle()
// Stale audio1 must be disposed, no extra play calls, speakingId untouched, no errors
assertTrue(audio1.isDisposed)
assertFalse(audio2.isDisposed)
assertEquals("msg-2", fixture.controller.speakingId.value)
assertEquals(audio2, fixture.player.currentlyPlaying)
assertEquals(1, fixture.player.playCallCount)
assertEquals(0, fixture.errors.size)
}
@Test
fun `noncooperative synthesis throwing failure after replacement is ignored and does not invoke onError`() =
runTest {
val fixture = Fixture(this)
val gate1 = CompletableDeferred<Unit>()
val resumeGate1 = CompletableDeferred<Unit>()
val audio2 = FakeAudio("Active audio 2")
fixture.synthesizer.synthesizeBlock = { req ->
if (req.messageId == "msg-1") {
try {
gate1.await()
} catch (t: Throwable) {
withContext(NonCancellable) {
resumeGate1.await()
}
}
throw IllegalStateException("Stale synthesis explosion")
} else {
audio2
}
}
val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First text")
val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second text")
fixture.controller.toggle(req1)
runCurrent()
// Replace with req2
fixture.controller.toggle(req2)
advanceUntilIdle()
assertEquals("msg-2", fixture.controller.speakingId.value)
assertEquals(0, fixture.errors.size)
// Release stale failure
resumeGate1.complete(Unit)
advanceUntilIdle()
// Stale failure must not invoke onError or clear msg-2
assertEquals(0, fixture.errors.size)
assertEquals("msg-2", fixture.controller.speakingId.value)
}
@Test
fun `stop and restart race on same request ensures stale inflight completion does not disrupt new cycle`() =
runTest {
val fixture = Fixture(this)
val gate1 = CompletableDeferred<Unit>()
val resumeGate1 = CompletableDeferred<Unit>()
val audio1 = FakeAudio("Cycle 1 audio")
val audio2 = FakeAudio("Cycle 2 audio")
var callIndex = 0
fixture.synthesizer.synthesizeBlock = {
val idx = ++callIndex
if (idx == 1) {
try {
gate1.await()
} catch (t: Throwable) {
withContext(NonCancellable) {
resumeGate1.await()
}
}
audio1
} else {
audio2
}
}
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Same text")
// Cycle 1: start synthesis
fixture.controller.toggle(req)
runCurrent()
// Toggle same request while synthesizing -> cancels cycle 1
fixture.controller.toggle(req)
runCurrent()
assertNull(fixture.controller.speakingId.value)
// Cycle 2: restart SAME request immediately
fixture.controller.toggle(req)
advanceUntilIdle()
// Cycle 2 has synthesized audio2 and is playing it
assertEquals("msg-1", fixture.controller.speakingId.value)
assertEquals(audio2, fixture.player.currentlyPlaying)
assertEquals(1, fixture.player.playCallCount)
// Stale cycle 1 finally returns audio1
resumeGate1.complete(Unit)
advanceUntilIdle()
// Stale audio1 must be disposed and cycle 2 remains active and playing audio2
assertTrue(audio1.isDisposed)
assertFalse(audio2.isDisposed)
assertEquals("msg-1", fixture.controller.speakingId.value)
assertEquals(audio2, fixture.player.currentlyPlaying)
assertEquals(1, fixture.player.playCallCount)
assertEquals(0, fixture.errors.size)
}
@Test
fun `stop stops playback and clears speakingId`() =
runTest {
val fixture = Fixture(this)
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world")
fixture.controller.toggle(req)
advanceUntilIdle()
assertEquals("msg-1", fixture.controller.speakingId.value)
val stopCallsBefore = fixture.player.stopCallCount
fixture.controller.stop()
advanceUntilIdle()
assertNull(fixture.controller.speakingId.value)
assertTrue(fixture.player.stopCallCount > stopCallsBefore)
}
@Test
fun `replacement immediately stops active playback and cancels old request`() =
runTest {
val fixture = Fixture(this)
val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First text")
val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second text")
fixture.controller.toggle(req1)
advanceUntilIdle()
assertEquals("msg-1", fixture.controller.speakingId.value)
fixture.controller.toggle(req2)
advanceUntilIdle()
assertEquals("msg-2", fixture.controller.speakingId.value)
assertEquals(2, fixture.synthesizer.calls.size)
assertEquals(2, fixture.player.playCallCount)
}
@Test
fun `replacement cancels inflight synthesis and generation fencing rejects stale completion`() =
runTest {
val fixture = Fixture(this)
val gate1 = CompletableDeferred<SpeechAudio>()
val gate2 = CompletableDeferred<SpeechAudio>()
fixture.synthesizer.synthesizeBlock = { req ->
if (req.messageId == "msg-1") gate1.await() else gate2.await()
}
val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First text")
val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second text")
fixture.controller.toggle(req1)
runCurrent()
fixture.controller.toggle(req2)
runCurrent()
// Complete old stale synthesis
val audio1 = FakeAudio("First text")
gate1.complete(audio1)
runCurrent()
// Complete new synthesis
val audio2 = FakeAudio("Second text")
gate2.complete(audio2)
advanceUntilIdle()
assertEquals("msg-2", fixture.controller.speakingId.value)
assertEquals(1, fixture.player.playCallCount)
assertEquals(audio2, fixture.player.currentlyPlaying)
assertEquals(0, fixture.errors.size)
}
@Test
fun `player onEnded callback resets speakingId to null`() =
runTest {
val fixture = Fixture(this)
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world")
fixture.controller.toggle(req)
advanceUntilIdle()
assertEquals("msg-1", fixture.controller.speakingId.value)
val onEnded = fixture.player.lastEndedCallback
onEnded?.invoke()
advanceUntilIdle()
assertNull(fixture.controller.speakingId.value)
assertEquals(0, fixture.errors.size)
}
@Test
fun `stale onEnded callback from superseded generation does not clear active speakingId`() =
runTest {
val fixture = Fixture(this)
val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First")
val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second")
fixture.controller.toggle(req1)
advanceUntilIdle()
val staleOnEnded = fixture.player.lastEndedCallback
fixture.controller.toggle(req2)
advanceUntilIdle()
assertEquals("msg-2", fixture.controller.speakingId.value)
staleOnEnded?.invoke()
advanceUntilIdle()
// Still speaking msg-2!
assertEquals("msg-2", fixture.controller.speakingId.value)
}
@Test
fun `stale onError callback from superseded generation is ignored and does not invoke onError`() =
runTest {
val fixture = Fixture(this)
val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First")
val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second")
fixture.controller.toggle(req1)
advanceUntilIdle()
val staleOnError = fixture.player.lastErrorCallback
fixture.controller.toggle(req2)
advanceUntilIdle()
assertEquals("msg-2", fixture.controller.speakingId.value)
staleOnError?.invoke()
advanceUntilIdle()
assertEquals("msg-2", fixture.controller.speakingId.value)
assertEquals(0, fixture.errors.size)
}
@Test
fun `player onError callback clears speakingId and triggers onError`() =
runTest {
val fixture = Fixture(this)
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world")
fixture.controller.toggle(req)
advanceUntilIdle()
assertEquals("msg-1", fixture.controller.speakingId.value)
val onError = fixture.player.lastErrorCallback
onError?.invoke()
advanceUntilIdle()
assertNull(fixture.controller.speakingId.value)
assertEquals(1, fixture.errors.size)
}
@Test
fun `synthesizer failure triggers onError and clears speakingId`() =
runTest {
val fixture = Fixture(this)
fixture.synthesizer.synthesizeBlock = {
throw IllegalStateException("Synthesis failed")
}
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world")
fixture.controller.toggle(req)
advanceUntilIdle()
assertNull(fixture.controller.speakingId.value)
assertEquals(1, fixture.errors.size)
assertEquals(0, fixture.player.playCallCount)
}
@Test
fun `cancellations during synthesis do not trigger onError`() =
runTest {
val fixture = Fixture(this)
val gate = CompletableDeferred<SpeechAudio>()
fixture.synthesizer.synthesizeBlock = { gate.await() }
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world")
fixture.controller.toggle(req)
runCurrent()
// Stop cancels the inflight coroutine
fixture.controller.stop()
advanceUntilIdle()
assertNull(fixture.controller.speakingId.value)
assertEquals(0, fixture.errors.size)
}
@Test
fun `replaying same request uses cached audio without re-synthesizing and stop preserves cache`() =
runTest {
val fixture = Fixture(this)
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Cached audio")
fixture.controller.toggle(req)
advanceUntilIdle()
assertEquals(1, fixture.synthesizer.calls.size)
assertEquals(1, fixture.player.playCallCount)
// Stop playback — must preserve replay cache
fixture.controller.stop()
advanceUntilIdle()
assertNull(fixture.controller.speakingId.value)
// Toggle again with same request -> should replay from cache without re-synthesizing
fixture.controller.toggle(req)
advanceUntilIdle()
assertEquals("msg-1", fixture.controller.speakingId.value)
assertEquals(1, fixture.synthesizer.calls.size)
assertEquals(2, fixture.player.playCallCount)
}
@Test
fun `request with different text invalidates cache and disposes old audio`() =
runTest {
val fixture = Fixture(this)
val audio1 = FakeAudio("Text 1")
val audio2 = FakeAudio("Text 2")
fixture.synthesizer.synthesizeBlock = { req ->
if (req.text == "Text 1") audio1 else audio2
}
val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Text 1")
fixture.controller.toggle(req1)
advanceUntilIdle()
assertEquals(audio1, fixture.player.currentlyPlaying)
assertFalse(audio1.isDisposed)
val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Text 2")
fixture.controller.toggle(req2)
advanceUntilIdle()
assertEquals(audio2, fixture.player.currentlyPlaying)
assertTrue(audio1.isDisposed)
assertEquals(2, fixture.synthesizer.calls.size)
}
@Test
fun `request with different scope invalidates cache and disposes old audio`() =
runTest {
val fixture = Fixture(this)
val audio1 = FakeAudio("Scope 1 audio")
val audio2 = FakeAudio("Scope 2 audio")
fixture.synthesizer.synthesizeBlock = { req ->
if (req.scopeKey == "session-1") audio1 else audio2
}
val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello")
fixture.controller.toggle(req1)
advanceUntilIdle()
assertFalse(audio1.isDisposed)
val req2 = SpeechRequest(scopeKey = "session-2", messageId = "msg-1", text = "Hello")
fixture.controller.toggle(req2)
advanceUntilIdle()
assertEquals(audio2, fixture.player.currentlyPlaying)
assertTrue(audio1.isDisposed)
assertEquals(2, fixture.synthesizer.calls.size)
}
@Test
fun `replaying cached request when player throws does not escape toggle and clears speakingId`() =
runTest {
val fixture = Fixture(this)
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Cached text")
// Initial successful playback
fixture.controller.toggle(req)
advanceUntilIdle()
assertEquals("msg-1", fixture.controller.speakingId.value)
assertEquals(1, fixture.synthesizer.calls.size)
assertEquals(1, fixture.player.playCallCount)
// Complete initial playback
fixture.player.lastEndedCallback?.invoke()
advanceUntilIdle()
assertNull(fixture.controller.speakingId.value)
assertEquals(0, fixture.errors.size)
// Configure player to throw synchronously on replay
val stopCallsBeforeReplay = fixture.player.stopCallCount
fixture.player.playThrows = IllegalStateException("Player init failed")
// Replay exact cached request
fixture.controller.toggle(req)
advanceUntilIdle()
// Must NOT escape toggle, speakingId null, onError exactly once,
// partially initialized player stopped, no resynthesis
assertNull(fixture.controller.speakingId.value)
assertEquals(1, fixture.errors.size)
assertTrue(fixture.player.stopCallCount > stopCallsBeforeReplay)
assertEquals(1, fixture.synthesizer.calls.size)
assertEquals(2, fixture.player.playCallCount)
// Replay cache is retained: if player recovers, replaying exact request does not re-synthesize
fixture.player.playThrows = null
fixture.controller.toggle(req)
advanceUntilIdle()
assertEquals("msg-1", fixture.controller.speakingId.value)
assertEquals(1, fixture.synthesizer.calls.size)
assertEquals(3, fixture.player.playCallCount)
assertEquals(1, fixture.errors.size)
}
@Test
fun `initial play when player throws does not escape toggle and clears speakingId`() =
runTest {
val fixture = Fixture(this)
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Initial throw text")
fixture.player.playThrows = IllegalStateException("Player failed synchronously on first play")
// Initial noncached play throw
fixture.controller.toggle(req)
advanceUntilIdle()
// Must NOT escape toggle, speakingId null, onError exactly once, stopped partially initialized player
assertNull(fixture.controller.speakingId.value)
assertEquals(1, fixture.errors.size)
assertTrue(fixture.player.stopCallCount >= 1)
assertEquals(1, fixture.synthesizer.calls.size)
assertEquals(1, fixture.player.playCallCount)
// Replay cache retained: if player recovers, next toggle on exact
// request uses cached audio without re-synthesizing
fixture.player.playThrows = null
fixture.controller.toggle(req)
advanceUntilIdle()
assertEquals("msg-1", fixture.controller.speakingId.value)
assertEquals(1, fixture.synthesizer.calls.size)
assertEquals(2, fixture.player.playCallCount)
assertEquals(1, fixture.errors.size)
}
@Test
fun `reset clears speakingId, stops player, and disposes cached audio`() =
runTest {
val fixture = Fixture(this)
val audio = FakeAudio("Reset audio")
fixture.synthesizer.synthesizeBlock = { audio }
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Reset audio")
fixture.controller.toggle(req)
advanceUntilIdle()
assertEquals("msg-1", fixture.controller.speakingId.value)
assertFalse(audio.isDisposed)
fixture.controller.reset()
advanceUntilIdle()
assertNull(fixture.controller.speakingId.value)
assertTrue(audio.isDisposed)
assertTrue(fixture.player.stopCallCount >= 1)
// Re-toggling same request after reset must re-synthesize because cache was cleared/disposed
val audio2 = FakeAudio("Reset audio 2")
fixture.synthesizer.synthesizeBlock = { audio2 }
fixture.controller.toggle(req)
advanceUntilIdle()
assertEquals(2, fixture.synthesizer.calls.size)
assertEquals("msg-1", fixture.controller.speakingId.value)
}
}
@@ -0,0 +1,250 @@
package com.m57.hermescontrol.ui.chat.components
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SpeechTextTest {
@Test
fun `blank input returns blank string`() {
assertEquals("", SpeechText.stripMarkdownForSpeech(""))
assertEquals("", SpeechText.stripMarkdownForSpeech(" "))
assertEquals("", SpeechText.stripMarkdownForSpeech("\n\t\n "))
}
@Test
fun `plain text is preserved without changes`() {
val input = "Hello world this is a test"
val result = SpeechText.stripMarkdownForSpeech(input)
assertEquals("Hello world this is a test", result.trim())
}
@Test
fun `bold and italic markers are removed keeping prose`() {
val input = "This is **bold** text and *italic* text and ***bold italic*** text"
val result = SpeechText.stripMarkdownForSpeech(input)
assertFalse(result.contains("*"))
assertTrue(result.contains("bold"))
assertTrue(result.contains("italic"))
assertTrue(result.contains("bold italic"))
}
@Test
fun `strikethrough markers are removed keeping inner text`() {
val input = "This is ~~cancelled~~ completed"
val result = SpeechText.stripMarkdownForSpeech(input)
assertFalse(result.contains("~"))
assertTrue(result.contains("cancelled"))
assertTrue(result.contains("completed"))
}
@Test
fun `headers keep title text without hash characters`() {
val input =
"""
# Header 1
## Header 2
### Header 3
""".trimIndent()
val result = SpeechText.stripMarkdownForSpeech(input)
assertFalse(result.contains("#"))
assertTrue(result.contains("Header 1"))
assertTrue(result.contains("Header 2"))
assertTrue(result.contains("Header 3"))
}
@Test
fun `inline code keeps code content without backticks`() {
val input = "Run `val my_value = 42` to configure"
val result = SpeechText.stripMarkdownForSpeech(input)
assertFalse(result.contains("`"))
assertTrue(result.contains("val my_value = 42"))
assertTrue(result.contains("my_value"))
assertTrue(result.contains("to configure"))
assertFalse(result.contains(" "))
}
@Test
fun `fenced code block keeps code body and removes fences and language identifier`() {
val input =
"""
Here is some code:
```kotlin
fun main() {
println("Hello")
}
```
Done.
""".trimIndent()
val result = SpeechText.stripMarkdownForSpeech(input)
assertFalse(result.contains("```"))
assertFalse(result.contains("kotlin"))
assertTrue(result.contains("fun main()"))
assertTrue(result.contains("""println("Hello")"""))
assertTrue(result.contains("Here is some code:"))
assertTrue(result.contains("Done."))
}
@Test
fun `fenced code block body retains underscores, generics, multiplication, and pipes`() {
val input =
"""
Here is complex code:
```typescript
const a_b_c: List<T> = items.map(x => x * 2 | 0);
```
Done...
""".trimIndent()
val result = SpeechText.stripMarkdownForSpeech(input)
assertFalse(result.contains("```"))
assertFalse(result.contains("typescript"))
assertTrue(result.contains("a_b_c"))
assertTrue(result.contains("<T>"))
assertTrue(result.contains("* 2"))
assertTrue(result.contains("| 0"))
assertTrue(result.contains("Here is complex code:"))
assertTrue(result.contains("Done..."))
}
@Test
fun `markdown links keep label text and strip destination url`() {
val input = "Check out [Hermes Documentation](https://hermes-agent.nousresearch.com/docs) for info"
val result = SpeechText.stripMarkdownForSpeech(input)
assertTrue(result.contains("Hermes Documentation"))
assertFalse(result.contains("https://hermes-agent.nousresearch.com/docs"))
assertFalse(result.contains("[") || result.contains("]"))
}
@Test
fun `image markdown keeps alt text and strips image url`() {
val input = "Here is a diagram: ![Architecture diagram](https://example.com/arch.png)"
val result = SpeechText.stripMarkdownForSpeech(input)
assertTrue(result.contains("Architecture diagram"))
assertFalse(result.contains("https://example.com/arch.png"))
assertFalse(result.contains("!["))
}
@Test
fun `bare urls are removed`() {
val input = "Visit https://example.com or http://test.org/path?arg=1 today"
val result = SpeechText.stripMarkdownForSpeech(input)
assertFalse(result.contains("https://example.com"))
assertFalse(result.contains("http://test.org/path?arg=1"))
assertTrue(result.contains("Visit"))
assertTrue(result.contains("today"))
}
@Test
fun `html tags are stripped keeping inner text`() {
val input = "<p>This is a <b>bold</b> paragraph with a <br/> break.</p>"
val result = SpeechText.stripMarkdownForSpeech(input)
assertFalse(result.contains("<"))
assertFalse(result.contains(">"))
assertTrue(result.contains("This is a"))
assertTrue(result.contains("bold"))
assertTrue(result.contains("paragraph with a"))
assertTrue(result.contains("break."))
}
@Test
fun `blockquote markers are removed keeping content`() {
val input =
"""
> This is a quote.
> It spans multiple lines.
""".trimIndent()
val result = SpeechText.stripMarkdownForSpeech(input)
assertFalse(result.contains(">"))
assertTrue(result.contains("This is a quote."))
assertTrue(result.contains("It spans multiple lines."))
}
@Test
fun `unordered and ordered list markers are handled sensibly`() {
val input =
"""
Items:
* First item
* Second item
1. Step one
2. Step two
""".trimIndent()
val result = SpeechText.stripMarkdownForSpeech(input)
assertFalse(result.contains("*"))
assertFalse(result.contains("1."))
assertFalse(result.contains("2."))
assertTrue(result.contains("First item"))
assertTrue(result.contains("Second item"))
assertTrue(result.contains("Step one"))
assertTrue(result.contains("Step two"))
}
@Test
fun `arabic text is preserved intact`() {
val input = "مرحبا بك في تطبيق هيرمس! **أهلا وسهلا**"
val result = SpeechText.stripMarkdownForSpeech(input)
assertFalse(result.contains("*"))
assertTrue(result.contains("مرحبا بك في تطبيق هيرمس!"))
assertTrue(result.contains("أهلا وسهلا"))
}
@Test
fun `unicode and emoji are preserved`() {
val input = "Hello 👋 world! 🚀 Enjoy the day ☀️"
val result = SpeechText.stripMarkdownForSpeech(input)
assertTrue(result.contains("👋"))
assertTrue(result.contains("🚀"))
assertTrue(result.contains("☀️"))
assertTrue(result.contains("Hello"))
assertTrue(result.contains("world!"))
}
@Test
fun `punctuation and code punctuation edge cases are handled without double punctuation`() {
val input = "Wait... is that it?! Yes, it is: done."
val result = SpeechText.stripMarkdownForSpeech(input)
assertTrue(result.contains("Wait..."))
assertTrue(result.contains("done."))
assertFalse(result.contains("..,"))
assertFalse(result.contains(",,"))
}
@Test
fun `newlines retain punctuation without introducing duplicate or mixed punctuation and normalize whitespace`() {
val input =
"""
Done.
Next!
مرحبا؟
Again
""".trimIndent()
val result = SpeechText.stripMarkdownForSpeech(input)
assertFalse(result.contains(". ."))
assertFalse(result.contains("!."))
assertFalse(result.contains("؟."))
assertFalse(result.contains("\n\n"))
assertTrue(result.contains("Done."))
assertTrue(result.contains("Next!"))
assertTrue(result.contains("مرحبا؟"))
assertTrue(result.contains("Again"))
}
@Test
fun `horizontal rules are removed`() {
val input =
"""
Section 1
---
Section 2
***
Section 3
""".trimIndent()
val result = SpeechText.stripMarkdownForSpeech(input)
assertTrue(result.contains("Section 1"))
assertTrue(result.contains("Section 2"))
assertTrue(result.contains("Section 3"))
assertFalse(result.contains("---"))
assertFalse(result.contains("***"))
}
}
@@ -0,0 +1,40 @@
package com.m57.hermescontrol.ui.chat.tool
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ConnectorToolLabelsTest {
@Test fun `gateway envelope labels reach the visible header without replacing real tool arguments`() {
val content = """{
"name":"tool_call",
"args":{"labels":["bug"]},
"labels":[{"app":"GitHub","text":"Create issue"}],
"result":{}
}"""
val view =
com.m57.hermescontrol.ui.chat
.parseToolOutput(content, "tool_call", false)
assertEquals("GitHub · Create issue", view?.serverDisplayName)
assertEquals("GitHub · Create issue", view?.title)
}
@Test fun `server connector labels title both live and restored tool calls`() {
val args =
Json.parseToJsonElement(
"""{"hermes_tool_labels":[{"app":"Google Drive","text":"List files","kind":"connector"}]}""",
)
val running = ToolViewBuilder.build("tool_call", args, null, running = true)
val restored = ToolViewBuilder.build("tool_call", args, Json.parseToJsonElement("{}"))
assertEquals("Google Drive · List files", running.title)
assertEquals(running.title, restored.title)
}
@Test fun `malformed metadata and actual labels argument keep fallback title`() {
val args = Json.parseToJsonElement("""{"labels":["bug"],"hermes_tool_labels":[null,42,{"app":"GitHub"}]}""")
val view = ToolViewBuilder.build("tool_call", args, null)
assertTrue(view.title.isNotBlank())
assertEquals(ToolViewBuilder.build("tool_call", null, null).title, view.title)
}
}
@@ -0,0 +1,207 @@
package com.m57.hermescontrol.ui.connectors
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.DataScope
import com.m57.hermescontrol.data.model.AccountConnectorResult
import com.m57.hermescontrol.data.model.ConnectorCatalogEntry
import com.m57.hermescontrol.data.model.ConnectorError
import com.m57.hermescontrol.data.model.ConnectorListResult
import com.m57.hermescontrol.data.model.ConnectorPolicy
import com.m57.hermescontrol.data.ws.AccountConnectorRepository
import com.m57.hermescontrol.data.ws.ConnectionOperationParser
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class AccountConnectorsViewModelTest {
private val dispatcher = StandardTestDispatcher()
private val repo = mockk<AccountConnectorRepository>()
private val scopes = MutableStateFlow<DataScope?>(DataScope("server", "http://host", "work", 1))
private val events = MutableSharedFlow<WsEvent>()
private lateinit var vm: AccountConnectorsViewModel
private val policy = ConnectorPolicy("effective", "unrestricted", emptyList(), emptyList(), emptyMap())
@Before fun setup() {
Dispatchers.setMain(dispatcher)
coEvery { repo.listConnectors() } returns ConnectorListResult.Success(true, emptyList())
coEvery { repo.catalog() } returns
AccountConnectorResult.Success(listOf(ConnectorCatalogEntry("drive", "Google Drive", "Files", "storage")))
coEvery { repo.accounts() } returns AccountConnectorResult.Success(emptyList())
coEvery { repo.policy() } returns
AccountConnectorResult.Success(policy.copy(member = policy.copy(revision = "member")))
coEvery { repo.operationStatus("op-a") } returns AccountConnectorResult.Success(snapshot())
vm = AccountConnectorsViewModel(repo, scopes, { scopes.value }, events)
}
@After fun teardown() {
vm.viewModelScope.cancel()
Dispatchers.resetMain()
}
@Test fun `no session required and connect retains the browser operation`() =
runTest(dispatcher) {
advanceUntilIdle()
assertFalse(vm.state.value.loading)
assertEquals(
"Google Drive",
vm.state.value.catalog
.single()
.name,
)
coEvery { repo.connect(listOf("drive"), false) } returns AccountConnectorResult.Success(snapshot())
vm.connect("drive")
vm.connect("drive")
advanceUntilIdle()
coVerify(exactly = 1) { repo.connect(listOf("drive"), false) }
assertEquals(
"https://example.com/auth",
vm.connectionState.value.operation
?.targets
?.single()
?.safeConnectUrl,
)
}
@Test fun `connect and remove errors remain visible rather than cleared by reload`() =
runTest(dispatcher) {
advanceUntilIdle()
coEvery { repo.connect(any(), any()) } returns
AccountConnectorResult.Failure(ConnectorError.RequestFailed("authorization failed"))
vm.connect("drive")
advanceUntilIdle()
assertEquals("authorization failed", vm.state.value.error)
coEvery { repo.remove("c1") } returns
AccountConnectorResult.Failure(ConnectorError.RequestFailed("remove failed"))
vm.remove("c1")
advanceUntilIdle()
assertEquals("remove failed", vm.state.value.error)
assertFalse(vm.state.value.busy)
}
@Test fun `profile switch clears operation and rejects prior account update`() =
runTest(dispatcher) {
advanceUntilIdle()
coEvery { repo.connect(any(), any()) } returns AccountConnectorResult.Success(snapshot())
vm.connect("drive")
advanceUntilIdle()
scopes.value = scopes.value?.copy(activeProfileId = "other")
advanceUntilIdle()
events.emit(WsEvent.ConnectionUpdate(snapshot().copy(seq = 2)))
advanceUntilIdle()
assertNull(vm.connectionState.value.operation)
assertEquals(
"other",
vm.state.value.scope
?.activeProfileId,
)
}
@Test fun `late tool response cannot populate a new account scope`() =
runTest(dispatcher) {
advanceUntilIdle()
val pending =
CompletableDeferred<AccountConnectorResult<List<com.m57.hermescontrol.data.model.ConnectorTool>>>()
coEvery { repo.tools("drive", false) } coAnswers { pending.await() }
vm.loadTools("drive")
dispatcher.scheduler.runCurrent()
scopes.value = scopes.value?.copy(inMemoryAuthGeneration = 2)
advanceUntilIdle()
pending.complete(AccountConnectorResult.Success(emptyList()))
advanceUntilIdle()
assertTrue(
vm.state.value.tools
.isEmpty(),
)
assertTrue(
vm.state.value.toolsLoading
.isEmpty(),
)
}
@Test fun `continue answers account operation and settlement closes it`() =
runTest(dispatcher) {
advanceUntilIdle()
coEvery { repo.connect(any(), any()) } returns AccountConnectorResult.Success(snapshot())
coEvery { repo.operationRequest(any(), any()) } returns mapOf("status" to "ok")
coEvery { repo.operationStatus("op-a") } returns
AccountConnectorResult.Success(snapshot().copy(seq = 2, settled = true))
vm.connect("drive")
advanceUntilIdle()
vm.continueOperation()
advanceUntilIdle()
coVerify {
repo.operationRequest(
WsMethods.CONNECTION_RESPOND,
mapOf(
"owner" to mapOf("type" to "account"),
"op_id" to "op-a",
"result" to mapOf("settled_by" to "continue"),
),
)
}
assertNull(vm.connectionState.value.operation)
}
@Test fun `session update never changes account operation`() =
runTest(dispatcher) {
advanceUntilIdle()
coEvery { repo.connect(any(), any()) } returns AccountConnectorResult.Success(snapshot())
vm.connect("drive")
advanceUntilIdle()
events.emit(
WsEvent.ConnectionUpdate(
snapshot().copy(seq = 3, settled = true, accountOwned = false, sessionId = "chat"),
),
)
advanceUntilIdle()
assertEquals(
1L,
vm.connectionState.value.operation
?.seq,
)
}
private fun snapshot() =
checkNotNull(
ConnectionOperationParser.parse(
mapOf(
"owner" to mapOf("type" to "account"),
"op_id" to "op-a",
"seq" to 1,
"deadline_at" to 2000000000.0,
"settled" to false,
"targets" to
listOf(
mapOf(
"name" to "drive",
"kind" to "connector",
"action" to "authorize",
"state" to "initiated",
"connect_url" to "https://example.com/auth",
),
),
),
),
)
}
@@ -0,0 +1,193 @@
package com.m57.hermescontrol.ui.mcp
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.McpServer
import com.m57.hermescontrol.data.model.McpServerTestResponse
import com.m57.hermescontrol.data.model.McpServersResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.HermesApiService
import com.m57.hermescontrol.data.remote.OkHttpProvider
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkAll
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import retrofit2.Response
@OptIn(ExperimentalCoroutinesApi::class)
class McpOwnershipTest {
private val dispatcher = StandardTestDispatcher()
private lateinit var api: HermesApiService
private val owned = McpServer(name = "scanner", enabled = true, source = "plugin", plugin = "security")
private val conflict = "Server 'scanner' is provided by plugin 'security' and cannot be modified"
@Before
fun setUp() {
Dispatchers.setMain(dispatcher)
api = mockk()
mockkObject(ApiClient, AuthManager)
every { ApiClient.hermesApi } returns api
every { AuthManager.currentDataScope() } throws IllegalStateException("No test scope")
}
@After
fun tearDown() {
Dispatchers.resetMain()
unmockkAll()
}
@Test
fun `ownership decodes and legacy servers remain editable`() {
val json = OkHttpProvider.json
val plugin =
json.decodeFromString<McpServer>(
"""{"name":"scanner","enabled":true,"source":"plugin","plugin":"security"}""",
)
assertEquals(owned, plugin)
assertTrue(plugin.isPluginOwned)
assertTrue(plugin.copy(plugin = null).isPluginOwned)
val legacy = json.decodeFromString<McpServer>("""{"name":"local","enabled":true}""")
assertFalse(legacy.isPluginOwned)
assertNull(legacy.plugin)
val config =
json.decodeFromString<McpServer>(
"""{"name":"local","enabled":true,"source":"config","plugin":null}""",
)
assertFalse(config.isPluginOwned)
}
@Test
fun `plugin mutations are blocked even for stale callbacks but testing remains available`() {
val vm = loaded(owned)
val stale = owned.copy(source = "config", plugin = null)
vm.toggleServer(stale)
vm.deleteServer(owned.name)
vm.startEditingEnv(stale)
vm.updateEnvKey("KEY")
vm.updateEnvValue("value")
vm.addEnvVar(owned.name)
vm.removeEnvVar(owned.name, "KEY")
vm.startMcpOAuthFlow(stale) { error("Must not open browser") }
dispatcher.scheduler.advanceUntilIdle()
assertTrue(
vm.uiState.value.servers
.single()
.enabled,
)
assertNull(vm.uiState.value.editingEnvFor)
coVerify(exactly = 0) { api.toggleMcpServer(any(), any()) }
coVerify(exactly = 0) { api.deleteMcpServer(any()) }
coVerify(exactly = 0) { api.updateMcpServer(any(), any()) }
coVerify(exactly = 0) { api.authMcpServer(any()) }
coEvery { api.testMcpServer(owned.name) } returns Response.success(McpServerTestResponse(ok = true))
vm.testServer(owned.name)
dispatcher.scheduler.advanceUntilIdle()
assertTrue(
vm.uiState.value.serverTestResults
.getValue(owned.name)
.ok,
)
}
@Test
fun `config server toggle and environment editing remain available`() {
val config = owned.copy(source = "config", plugin = null)
val vm = loaded(config)
coEvery { api.toggleMcpServer(config.name, any()) } returns Response.success(Unit)
vm.toggleServer(config)
dispatcher.scheduler.advanceUntilIdle()
assertFalse(
vm.uiState.value.servers
.single()
.enabled,
)
vm.startEditingEnv(config)
assertEquals(config.name, vm.uiState.value.editingEnvFor)
coEvery { api.updateMcpServer(config.name, any()) } returns Response.success(config)
vm.updateEnvKey("KEY")
vm.updateEnvValue("value")
vm.addEnvVar(config.name)
dispatcher.scheduler.advanceUntilIdle()
coVerify { api.updateMcpServer(config.name, mapOf("env" to mapOf("KEY" to "value"))) }
vm.removeEnvVar(config.name, "KEY")
dispatcher.scheduler.advanceUntilIdle()
coVerify { api.updateMcpServer(config.name, mapOf("env" to emptyMap<String, String>())) }
coEvery { api.deleteMcpServer(config.name) } returns Response.success(Unit)
vm.deleteServer(config.name)
dispatcher.scheduler.advanceUntilIdle()
coVerify(exactly = 1) { api.deleteMcpServer(config.name) }
}
@Test
fun `stale ownership conflicts surface detail verbatim and revert toggle`() {
val config = owned.copy(source = "config", plugin = null)
val vm = loaded(config)
coEvery { api.toggleMcpServer(any(), any()) } returns conflictResponse()
vm.toggleServer(config)
dispatcher.scheduler.advanceUntilIdle()
assertTrue(
vm.uiState.value.servers
.single()
.enabled,
)
assertEquals(conflict, vm.uiState.value.toastMessage)
coEvery { api.deleteMcpServer(any()) } returns conflictResponse()
vm.deleteServer(config.name)
dispatcher.scheduler.advanceUntilIdle()
assertEquals(conflict, vm.uiState.value.toastMessage)
coEvery { api.updateMcpServer(any(), any()) } coAnswers { conflictResponse() }
vm.updateEnvKey("KEY")
vm.addEnvVar(config.name)
dispatcher.scheduler.advanceUntilIdle()
assertEquals(conflict, vm.uiState.value.toastMessage)
vm.clearToast()
vm.removeEnvVar(config.name, "KEY")
dispatcher.scheduler.advanceUntilIdle()
assertEquals(conflict, vm.uiState.value.toastMessage)
coEvery { api.authMcpServer(any()) } returns conflictResponse()
vm.clearToast()
vm.startMcpOAuthFlow(config) { error("Must not open browser") }
dispatcher.scheduler.advanceUntilIdle()
assertEquals(conflict, vm.uiState.value.toastMessage)
}
@Test
fun `add collision surfaces plugin conflict verbatim`() {
coEvery { api.addMcpServer(any()) } returns conflictResponse()
val vm = McpServersViewModel(ioDispatcher = dispatcher)
vm.toggleAddForm()
vm.updateAddServerName(owned.name)
vm.updateAddServerUrl("https://example.com/mcp")
vm.submitAddServer()
dispatcher.scheduler.advanceUntilIdle()
assertEquals(conflict, vm.uiState.value.toastMessage)
assertFalse(vm.uiState.value.addingServer)
assertTrue(vm.uiState.value.showAddForm)
assertEquals(owned.name, vm.uiState.value.addServerName)
}
private fun loaded(server: McpServer): McpServersViewModel {
coEvery { api.getMcpServers() } returns Response.success(McpServersResponse(listOf(server)))
return McpServersViewModel(ioDispatcher = dispatcher).also {
it.loadServers()
dispatcher.scheduler.advanceUntilIdle()
assertEquals(listOf(server), it.uiState.value.servers)
}
}
private fun <T> conflictResponse(): Response<T> = Response.error(409, """{"detail":"$conflict"}""".toResponseBody())
}
@@ -5,6 +5,8 @@ import com.m57.hermescontrol.data.model.PluginCatalogCapabilities
import com.m57.hermescontrol.data.model.PluginCatalogEntry
import com.m57.hermescontrol.data.model.PluginCatalogResponse
import com.m57.hermescontrol.data.model.PluginInfo
import com.m57.hermescontrol.data.model.PluginUpdateRequest
import com.m57.hermescontrol.data.model.PluginUpdateResult
import com.m57.hermescontrol.data.model.PluginsHubResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.HermesApiService
@@ -72,6 +74,107 @@ class PluginsViewModelTest {
unmockkAll()
}
@Test
fun `update waits for consent and only accepts on explicit confirmation`() {
coEvery { mockApi.updatePlugin("snyk", PluginUpdateRequest(false)) } returns
Response.success(PluginUpdateResult(consentRequired = true, deltaLines = listOf("tools: scan")))
coEvery { mockApi.updatePlugin("snyk", PluginUpdateRequest(true)) } returns
Response.success(PluginUpdateResult(ok = true))
coEvery { mockApi.getPlugins() } returns Response.success(PluginsHubResponse(emptyList()))
val viewModel = PluginsViewModel()
viewModel.updatePlugin("snyk")
viewModel.updatePlugin("snyk")
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(
listOf("tools: scan"),
viewModel.uiState.value.updateConsent
?.result
?.capabilityDelta,
)
assertNull(viewModel.uiState.value.toastMessage)
assertNull(viewModel.uiState.value.rowBusy)
coVerify(exactly = 0) { mockApi.updatePlugin(any(), PluginUpdateRequest(true)) }
coVerify(exactly = 0) { mockApi.getPlugins() }
viewModel.confirmPluginUpdate()
viewModel.confirmPluginUpdate()
testDispatcher.scheduler.advanceUntilIdle()
assertNull(viewModel.uiState.value.updateConsent)
assertEquals("Plugin updated successfully", viewModel.uiState.value.toastMessage)
coVerify(exactly = 1) { mockApi.updatePlugin("snyk", PluginUpdateRequest(false)) }
coVerify(exactly = 1) { mockApi.updatePlugin("snyk", PluginUpdateRequest(true)) }
coVerify(exactly = 1) { mockApi.getPlugins() }
}
@Test
fun `cancel consent never retries the update`() {
coEvery { mockApi.updatePlugin(any(), any()) } returns
Response.success(PluginUpdateResult(consentRequired = true))
val viewModel = PluginsViewModel()
viewModel.updatePlugin("snyk")
testDispatcher.scheduler.advanceUntilIdle()
viewModel.cancelPluginUpdate()
viewModel.confirmPluginUpdate()
testDispatcher.scheduler.advanceUntilIdle()
assertNull(viewModel.uiState.value.updateConsent)
assertNull(viewModel.uiState.value.toastMessage)
coVerify(exactly = 1) { mockApi.updatePlugin(any(), any()) }
}
@Test
fun `ok false is a failure even with HTTP 200 and unchanged true`() {
coEvery { mockApi.updatePlugin(any(), any()) } returns
Response.success(PluginUpdateResult(unchanged = true, error = "Update refused"))
val viewModel = PluginsViewModel()
viewModel.updatePlugin("snyk")
testDispatcher.scheduler.advanceUntilIdle()
assertEquals("Failed to update plugin: Update refused", viewModel.uiState.value.toastMessage)
assertNull(viewModel.uiState.value.rowBusy)
coVerify(exactly = 0) { mockApi.getPlugins() }
}
@Test
fun `unchanged success reports already up to date`() {
coEvery { mockApi.updatePlugin(any(), any()) } returns
Response.success(PluginUpdateResult(ok = true, unchanged = true))
coEvery { mockApi.getPlugins() } returns Response.success(PluginsHubResponse(emptyList()))
val viewModel = PluginsViewModel()
viewModel.updatePlugin("snyk")
testDispatcher.scheduler.advanceUntilIdle()
assertEquals("Plugin is already up to date", viewModel.uiState.value.toastMessage)
assertNull(viewModel.uiState.value.rowBusy)
}
@Test
fun `scope switch discards late update consent`() {
val reply = CompletableDeferred<Response<PluginUpdateResult>>()
coEvery { mockApi.updatePlugin(any(), any()) } coAnswers { reply.await() }
val viewModel = PluginsViewModel()
viewModel.updatePlugin("snyk")
testDispatcher.scheduler.runCurrent()
viewModel.clearScopeOwnedState()
reply.complete(Response.success(PluginUpdateResult(consentRequired = true)))
testDispatcher.scheduler.advanceUntilIdle()
viewModel.confirmPluginUpdate()
assertNull(viewModel.uiState.value.updateConsent)
assertNull(viewModel.uiState.value.rowBusy)
assertNull(viewModel.uiState.value.toastMessage)
coVerify(exactly = 1) { mockApi.updatePlugin(any(), any()) }
}
@Test
fun `HTTP update failure releases busy state`() {
coEvery { mockApi.updatePlugin(any(), any()) } returns Response.error(503, "offline".toResponseBody())
val viewModel = PluginsViewModel()
viewModel.updatePlugin("snyk")
testDispatcher.scheduler.advanceUntilIdle()
assertTrue(
viewModel.uiState.value.toastMessage!!
.contains("Failed to update"),
)
assertNull(viewModel.uiState.value.rowBusy)
coVerify(exactly = 0) { mockApi.getPlugins() }
}
@Test
fun `switch tab to CATALOG automatically triggers catalog load`() {
coEvery { mockApi.getPluginCatalog() } returns
@@ -99,6 +99,72 @@ class SessionsViewModelTest {
unmockkAll()
}
@Test
fun `corrupt refresh retains history and recovery replaces it`() {
coEvery { mockApi.getSessions(any(), any(), any(), any(), any()) } returnsMany
listOf(
Response.success(SessionListResponse(listOf(SessionInfo("cached")), total = 1)),
Response.success(
SessionListResponse(emptyList(), storage = mapOf("default" to "corrupt")),
),
Response.success(SessionListResponse(emptyList())),
)
val vm = createViewModel()
vm.loadSessions()
testDispatcher.scheduler.advanceUntilIdle()
vm.loadSessions(forceRefresh = true)
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(
listOf("cached"),
vm.uiState.value.sessions
.map { it.id },
)
assertEquals(setOf("default"), vm.uiState.value.corruptStorageProfiles)
assertFalse(vm.uiState.value.isLoading)
assertFalse(vm.uiState.value.hasMore)
vm.loadSessions(forceRefresh = true)
testDispatcher.scheduler.advanceUntilIdle()
assertTrue(
vm.uiState.value.sessions
.isEmpty(),
)
assertTrue(
vm.uiState.value.corruptStorageProfiles
.isEmpty(),
)
}
@Test
fun `corrupt first page retains partial rows and flags empty history`() {
val response =
kotlinx.serialization.json.Json.decodeFromString<SessionListResponse>(
"""{"sessions":[],"storage":{"work":"corrupt"}}""",
)
coEvery { mockApi.getSessions(any(), any(), any(), any(), any()) } returns Response.success(response)
val vm = createViewModel()
vm.loadSessions()
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(setOf("work"), vm.uiState.value.corruptStorageProfiles)
assertFalse(vm.uiState.value.isLoading)
coEvery { mockApi.getSessions(any(), any(), any(), any(), any()) } returns
Response.success(response.copy(sessions = listOf(SessionInfo("partial"))))
vm.loadSessions(forceRefresh = true)
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(
listOf("partial"),
vm.uiState.value.sessions
.map { it.id },
)
assertEquals(setOf("work"), vm.uiState.value.corruptStorageProfiles)
assertTrue(
kotlinx.serialization.json.Json
.decodeFromString<SessionListResponse>(
"""{"sessions":[]}""",
).storage
.isEmpty(),
)
}
@Test
fun `wire paging metadata drives a second request without changing history paging`() {
val first =